feat(domains): Phase D slices — auction, visitor/leads, CMS, public site, docs/logistics/marketing (V14~V20)

- Backend modules: auction (M15), visitor+lead scoring (M10), cms (M17), publicsite, document/milestone (M6), logistics dock reservation (M8), marketing campaign/sponsorship (M12), admin ruleset APIs
- Flyway V14~V20 schemas for the above
- Frontend: API clients wired (cms/marketing/public/visitor) + admin module pages refreshed
- Verified: gradlew compileJava SUCCESS, tsc -b clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 08:49:50 +09:00
parent abc9f83690
commit 2854f068ed
95 changed files with 8206 additions and 1689 deletions

View File

@ -0,0 +1,46 @@
package com.zioinfo.kintex.admin;
import com.fasterxml.jackson.databind.JsonNode;
import com.zioinfo.kintex.admin.dto.RulesetSummaryDto;
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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* SCR-A8 규정 룰셋 버전 조회 API (M18). 시스템관리자(홀매니저/ADMIN) 접근. 읽기 전용.
* 룰셋은 classpath 리소스로 버전 관리되며 편집 엔드포인트는 제공하지 않는다(규정 개정 = 리소스 교체).
*/
@RestController
@RequestMapping("/api/admin/rulesets")
public class AdminRulesetController {
private final AdminRulesetService service;
private final SystemAccessGuard guard;
public AdminRulesetController(AdminRulesetService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET — 룰셋 파일 요약 목록(파일명·버전·규칙 수·모듈 분포). */
@GetMapping
public ApiResponse<List<RulesetSummaryDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.list());
}
/** GET — 지정 룰셋의 규칙 전문(JSON 원문). */
@GetMapping("/{name}")
public ApiResponse<JsonNode> detail(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String name) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.detail(name));
}
}

View File

@ -0,0 +1,113 @@
package com.zioinfo.kintex.admin;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.admin.dto.RulesetSummaryDto;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 관리자 룰셋 조회 서비스 classpath {@code rulesets/*.json}(규정·요율) 열거·파싱해 버전 이력과 규칙 전문을 제공한다.
* 룰셋은 코드가 아닌 데이터로 버전 관리되므로(규정 개정 대응) DB 없이 리소스만 조회한다. 편집 없음(읽기 전용).
*/
@Service
public class AdminRulesetService {
private static final Logger log = LoggerFactory.getLogger(AdminRulesetService.class);
/** 파일명 화이트리스트(경로 트래버설 차단) — 열거된 리소스명과 이중 대조한다. */
private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9._-]+\\.json");
private final ObjectMapper objectMapper;
private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
public AdminRulesetService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/** 룰셋 파일 요약 목록(파일명·버전·규칙 수·모듈 분포). */
public List<RulesetSummaryDto> list() {
List<RulesetSummaryDto> out = new ArrayList<>();
for (Resource r : enumerate()) {
String name = r.getFilename();
if (name == null) {
continue;
}
JsonNode root = readTree(r, name);
if (root == null) {
continue;
}
out.add(summarize(name, root));
}
out.sort((a, b) -> b.name().compareTo(a.name())); // 최신 버전 파일이 위로
return out;
}
/** 지정 룰셋 파일의 규칙 전문(JSON 원문 파싱 트리). 존재하지 않으면 404. */
public JsonNode detail(String name) {
if (name == null || !SAFE_NAME.matcher(name).matches()) {
throw new ApiException(ErrorCode.VALIDATION, "룰셋 파일명이 유효하지 않습니다.");
}
for (Resource r : enumerate()) {
if (name.equals(r.getFilename())) {
JsonNode root = readTree(r, name);
if (root == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "룰셋을 읽을 수 없습니다.");
}
return root;
}
}
throw new ApiException(ErrorCode.NOT_FOUND, "룰셋을 찾을 수 없습니다.");
}
private RulesetSummaryDto summarize(String name, JsonNode root) {
String version = root.path("rulesetVersion").asText(name);
String effectiveDate = root.hasNonNull("effectiveDate") ? root.get("effectiveDate").asText() : null;
JsonNode rules = root.path("rules");
int ruleCount = rules.isArray() ? rules.size() : 0;
Map<String, Integer> dist = new LinkedHashMap<>();
if (rules.isArray()) {
for (JsonNode rule : rules) {
JsonNode modules = rule.path("module");
if (modules.isArray()) {
for (JsonNode m : modules) {
dist.merge(m.asText(), 1, Integer::sum);
}
}
}
}
return new RulesetSummaryDto(name, version, effectiveDate, ruleCount, dist);
}
private Resource[] enumerate() {
try {
return resolver.getResources("classpath:rulesets/*.json");
} catch (IOException e) {
log.error("룰셋 리소스 열거 실패: {}", e.getMessage());
return new Resource[0];
}
}
private JsonNode readTree(Resource r, String name) {
try (InputStream in = r.getInputStream()) {
return objectMapper.readTree(in);
} catch (IOException e) {
log.error("룰셋 파싱 실패({}): {}", name, e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.kintex.admin.dto;
import java.util.Map;
/**
* 규정/요율 룰셋 파일 요약(SCR-A8 룰셋 버전 조회). 편집이 아닌 조회 전용 룰셋은 classpath 리소스로 버전 관리된다.
*
* @param name 리소스 파일명 (: compliance-v1.json)
* @param version rulesetVersion 필드 (없으면 파일명)
* @param effectiveDate 발효일(있는 경우)
* @param ruleCount rules 배열 규칙 (요율 마스터는 0)
* @param moduleDistribution 규칙이 참조하는 모듈(M2·M3 ) 개수 분포
*/
public record RulesetSummaryDto(
String name,
String version,
String effectiveDate,
int ruleCount,
Map<String, Integer> moduleDistribution
) {
}

View File

@ -0,0 +1,84 @@
package com.zioinfo.kintex.auction;
import com.zioinfo.kintex.auction.dto.AuctionDtos.*;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.audit.Audited;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* M15 공사/장치 옥션 API (SCR-26·27·29).
* 인증 필수. 개설=발주자(주최자/참가업체/홀매니저), 응찰=등록 장치업체, 낙찰/전체비교=발주자.
* <p>봉인 입찰·등록업체 게이트는 {@link AuctionService} 서버에서 강제한다(컨트롤러는 인증만 확인).
*/
@RestController
@RequestMapping("/api/auctions")
public class AuctionController {
private final AuctionService service;
private final EventAccessGuard guard;
public AuctionController(AuctionService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET /api/auctions?eventId=&page=&size= — 옥션 목록(카드). */
@GetMapping
public ApiResponse<List<AuctionSummary>> list(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String eventId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "100") int size) {
guard.require(principal);
return ApiResponse.ok(service.list(eventId, page, size));
}
/** POST /api/auctions — 옥션 개설(발주자 권한, 서비스에서 검증). */
@Audited(action = "AUCTION_CREATE", targetType = "auction")
@PostMapping
public ApiResponse<AuctionSummary> create(@AuthenticationPrincipal KintexPrincipal principal,
@RequestBody CreateAuctionRequest req) {
guard.require(principal);
return ApiResponse.ok(service.create(principal, req));
}
/** GET /api/auctions/{id} — 상세·봉인 순위. 타사 금액·업체명은 서버가 마스킹. */
@GetMapping("/{id}")
public ApiResponse<AuctionDetail> detail(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.getDetail(id, principal));
}
/** POST /api/auctions/{id}/bids — 응찰(견적서 제출). 등록업체만·마감 후 409. */
@Audited(action = "AUCTION_BID", targetType = "bid")
@PostMapping("/{id}/bids")
public ApiResponse<BidResult> bid(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestBody BidRequest req) {
guard.require(principal);
return ApiResponse.ok(service.placeBid(id, principal, req));
}
/** POST /api/auctions/{id}/award — 낙찰(발주자·마감 후만). 사유 필수·감사 추적. */
@Audited(action = "AUCTION_AWARD", targetType = "award")
@PostMapping("/{id}/award")
public ApiResponse<AwardResult> award(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestBody AwardRequest req) {
guard.require(principal);
return ApiResponse.ok(service.award(id, principal, req));
}
/** GET /api/auctions/{id}/award-view — 마감 후 발주자 전용 전체 견적 비교(봉인 해제). */
@GetMapping("/{id}/award-view")
public ApiResponse<AwardView> awardView(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.awardView(id, principal));
}
}

View File

@ -0,0 +1,228 @@
package com.zioinfo.kintex.auction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/**
* M15 옥션 매퍼 auction·auction_invite·bid·award·company_reputation.
* <p><b> Map 반환 @Select 별칭은 반드시 쌍따옴표(AS "x")</b> PG는 따옴표 없는 별칭을 소문자로 접어
* Map 키가 전부 null이 된다(WORK_STATUS §7). 신규 매퍼 필수 점검.
* <p>봉인 입찰 보안은 서비스 레이어에서 강제한다 매퍼는 원천 데이터를 반환하되,
* 응찰자 뷰로 나가는 순위/상세는 서비스가 타사 금액·업체명을 마스킹한다.
*/
@Mapper
public interface AuctionMapper {
String AUCTION_COLS = """
SELECT a.id,
a.event_id AS "eventId",
a.title,
a.category,
a.auction_type AS "auctionType",
a.award_criteria AS "awardCriteria",
a.weight_price AS "weightPrice",
a.weight_reputation AS "weightReputation",
a.weight_delivery AS "weightDelivery",
a.round,
to_char(a.deadline AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "deadline",
CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday",
(a.deadline < now()) AS "closed",
a.materials,
a.accent,
a.material_package_id AS "materialPackageId",
(SELECT count(*) FROM bid b WHERE b.auction_id = a.id)::int AS "bidderCount",
(SELECT min(b.total) FROM bid b WHERE b.auction_id = a.id) AS "lowestPrice",
aw.bid_id AS "awardBidId",
wc.name AS "awardedCompany",
wb.total AS "finalPrice"
FROM auction a
LEFT JOIN award aw ON aw.auction_id = a.id
LEFT JOIN bid wb ON wb.id = aw.bid_id
LEFT JOIN company wc ON wc.id = wb.company_id
""";
/** 옥션 목록 — eventId 선택 필터. 마감 임박(deadline) 우선 정렬. */
@Select("<script>" + AUCTION_COLS
+ "<where>"
+ " <if test='eventId != null and eventId != \"\"'>AND a.event_id = #{eventId}</if>"
+ "</where>"
+ " ORDER BY a.deadline DESC, a.created_at DESC LIMIT #{limit} OFFSET #{offset}"
+ "</script>")
List<Map<String, Object>> listAuctions(@Param("eventId") String eventId,
@Param("limit") int limit,
@Param("offset") int offset);
/** 옥션 단건. */
@Select("<script>" + AUCTION_COLS + " WHERE a.id = #{id}</script>")
Map<String, Object> findAuction(@Param("id") String id);
/** 순위용 원천 응찰 목록(총액 오름차순). 서비스가 봉인 마스킹한다. */
@Select("""
SELECT b.company_id AS "companyId",
b.total,
b.lead_days AS "leadDays"
FROM bid b
WHERE b.auction_id = #{auctionId}
ORDER BY b.total ASC, b.submitted_at ASC
""")
List<Map<String, Object>> listRanking(@Param("auctionId") String auctionId);
/** 발주자 뷰(마감 후) — 전 견적 + 업체명 + 평판 공개. */
@Select("""
SELECT b.id AS "bidId",
b.company_id AS "companyId",
c.name AS "companyName",
b.total,
b.lead_days AS "leadDays",
COALESCE(r.rating, 4.0) AS "rating",
COALESCE(r.jobs_done, 0) AS "jobsDone",
COALESCE(r.claim_rate, 0) AS "claimRate"
FROM bid b
JOIN company c ON c.id = b.company_id
LEFT JOIN company_reputation r ON r.company_id = b.company_id
WHERE b.auction_id = #{auctionId}
ORDER BY b.total ASC
""")
List<Map<String, Object>> listQuotes(@Param("auctionId") String auctionId);
/** 사용자의 CONTRACTOR 소속 등록업체(행사 스코프). 봉인/응찰 게이트용. */
@Select("""
SELECT em.company_id AS "companyId",
c.name AS "companyName",
c.registered AS "registered"
FROM event_member em
JOIN company c ON c.id = em.company_id
WHERE em.user_id = #{userId}
AND em.event_id = #{eventId}
AND em.role_code = 'CONTRACTOR'
AND em.company_id IS NOT NULL
LIMIT 1
""")
Map<String, Object> findContractorCompany(@Param("userId") String userId,
@Param("eventId") String eventId);
/** 옥션 초대(등록업체) 총 수 — 0이면 공개 옥션으로 간주. */
@Select("SELECT count(*) FROM auction_invite WHERE auction_id = #{auctionId}")
int countInvites(@Param("auctionId") String auctionId);
@Select("SELECT count(*) FROM auction_invite WHERE auction_id = #{auctionId} AND company_id = #{companyId}")
int isInvited(@Param("auctionId") String auctionId, @Param("companyId") String companyId);
@Select("""
SELECT b.id, b.total, b.version, b.lead_days AS "leadDays"
FROM bid b WHERE b.auction_id = #{auctionId} AND b.company_id = #{companyId}
""")
Map<String, Object> findBid(@Param("auctionId") String auctionId, @Param("companyId") String companyId);
@Insert("""
INSERT INTO bid (id, auction_id, company_id, bidder_user_id, round,
subtotal, vat, total, lead_days, valid_until, terms, lines)
VALUES (#{id}, #{auctionId}, #{companyId}, #{bidderUserId}, #{round},
#{subtotal}, #{vat}, #{total}, #{leadDays},
CAST(#{validUntil} AS date), #{terms}, CAST(#{lines} AS jsonb))
""")
int insertBid(Map<String, Object> p);
@Update("""
UPDATE bid SET subtotal=#{subtotal}, vat=#{vat}, total=#{total}, lead_days=#{leadDays},
valid_until=CAST(#{validUntil} AS date), terms=#{terms}, lines=CAST(#{lines} AS jsonb),
round=#{round}, version = version + 1, bidder_user_id=#{bidderUserId}, updated_at=now()
WHERE auction_id=#{auctionId} AND company_id=#{companyId}
""")
int updateBid(Map<String, Object> p);
@Select("""
SELECT aw.id, aw.bid_id AS "bidId", aw.reason,
c.name AS "companyName",
to_char(aw.awarded_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "awardedAt"
FROM award aw
JOIN bid b ON b.id = aw.bid_id
JOIN company c ON c.id = b.company_id
WHERE aw.auction_id = #{auctionId}
""")
Map<String, Object> findAward(@Param("auctionId") String auctionId);
@Select("SELECT auction_id FROM bid WHERE id = #{bidId}")
String findAuctionIdOfBid(@Param("bidId") String bidId);
@Insert("""
INSERT INTO award (id, auction_id, bid_id, reason, awarded_by)
VALUES (#{id}, #{auctionId}, #{bidId}, #{reason}, #{awardedBy})
""")
int insertAward(Map<String, Object> p);
@Insert("""
INSERT INTO auction (id, event_id, title, category, auction_type, award_criteria,
weight_price, weight_reputation, weight_delivery, round, deadline,
material_package_id, materials, accent, created_by)
VALUES (#{id}, #{eventId}, #{title}, #{category}, #{auctionType}, #{awardCriteria},
#{weightPrice}, #{weightReputation}, #{weightDelivery}, #{round},
CAST(#{deadline} AS timestamptz), #{materialPackageId}, #{materials}, #{accent}, #{createdBy})
""")
int insertAuction(Map<String, Object> p);
@Insert("""
INSERT INTO auction_invite (id, auction_id, company_id)
VALUES (#{id}, #{auctionId}, #{companyId})
ON CONFLICT (auction_id, company_id) DO NOTHING
""")
int insertInvite(Map<String, Object> p);
/** 초대 후보 검증 — 등록업체(registered=true)만 통과. 미등록 id는 반환되지 않는다. */
@Select("<script>"
+ "SELECT id FROM company WHERE registered = true AND id IN "
+ "<foreach item='cid' collection='ids' open='(' separator=',' close=')'>#{cid}</foreach>"
+ "</script>")
List<String> filterRegistered(@Param("ids") List<String> ids);
// 업체 포털 대시보드(SCR-38)
/** 사용자의 CONTRACTOR 소속 등록업체(행사 무관 — 대시보드용). */
@Select("""
SELECT em.company_id AS "companyId", c.name AS "companyName", c.registered AS "registered"
FROM event_member em
JOIN company c ON c.id = em.company_id
WHERE em.user_id = #{userId} AND em.role_code = 'CONTRACTOR' AND em.company_id IS NOT NULL
ORDER BY em.created_at DESC
LIMIT 1
""")
Map<String, Object> findUserCompany(@Param("userId") String userId);
/** 내 업체의 응찰 목록(진행중 옥션) — 자사 금액만. */
@Select("""
SELECT a.id AS "auctionId", a.title AS "auctionTitle",
b.total AS "myPrice",
CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday",
(a.deadline < now()) AS "closed"
FROM bid b
JOIN auction a ON a.id = b.auction_id
WHERE b.company_id = #{companyId}
ORDER BY a.deadline ASC
""")
List<Map<String, Object>> listMyBids(@Param("companyId") String companyId);
/** 특정 옥션에서 내 총액보다 낮은 응찰 수(내 순위 = cnt + 1). 봉인 안전(카운트만). */
@Select("""
SELECT count(*)::int FROM bid b
WHERE b.auction_id = #{auctionId} AND b.total < #{myTotal}
""")
int lowerBidCount(@Param("auctionId") String auctionId, @Param("myTotal") long myTotal);
/** 내 업체가 낙찰받은 부스(수주). */
@Select("""
SELECT a.id AS "auctionId", a.title AS "auctionTitle", a.category, e.name AS "eventName",
CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday"
FROM award aw
JOIN bid b ON b.id = aw.bid_id
JOIN auction a ON a.id = aw.auction_id
JOIN event e ON e.id = a.event_id
WHERE b.company_id = #{companyId}
ORDER BY aw.awarded_at DESC
""")
List<Map<String, Object>> listMyAwardedBooths(@Param("companyId") String companyId);
}

View File

@ -0,0 +1,53 @@
package com.zioinfo.kintex.auction;
import com.zioinfo.kintex.auction.dto.AuctionDtos.Weights;
/**
* 낙찰 종합점수 산식(순수 함수 단위 테스트 대상).
*
* <p> 축을 0~100 으로 정규화한 가중 평균한다. 모두 <b>높을수록 우수</b>:
* <ul>
* <li><b>가격</b>: minTotal/total × 100 최저가가 100점, 비쌀수록 감점.</li>
* <li><b>평판</b>: rating/5 × 100 5점 만점 평점.</li>
* <li><b>납기</b>: minLead/lead × 100 가장 빠른 납기가 100점.</li>
* </ul>
* 낙찰기준 {@code lowest} 가격 점수만 사용, {@code comprehensive} 가중 평균.
* 가중치 합이 0이면 분모를 1로 보정한다.
*/
public final class AuctionScoring {
private AuctionScoring() {
}
public static double priceScore(long total, long minTotal) {
if (total <= 0) return 0.0;
return minTotal > 0 ? (double) minTotal / total * 100.0 : 100.0;
}
public static double reputationScore(double rating) {
double clamped = Math.max(0.0, Math.min(rating, 5.0));
return clamped / 5.0 * 100.0;
}
public static double deliveryScore(int lead, int minLead) {
return lead > 0 && minLead > 0 ? (double) minLead / lead * 100.0 : 100.0;
}
/** 종합점수(소수 1자리 반올림). */
public static double score(String criteria, Weights w,
long total, long minTotal, int lead, int minLead, double rating) {
double price = priceScore(total, minTotal);
if ("lowest".equals(criteria)) {
return round1(price);
}
double rep = reputationScore(rating);
double delivery = deliveryScore(lead, minLead);
int denom = Math.max(w.price() + w.reputation() + w.delivery(), 1);
double weighted = (price * w.price() + rep * w.reputation() + delivery * w.delivery()) / denom;
return round1(weighted);
}
public static double round1(double v) {
return Math.round(v * 10.0) / 10.0;
}
}

View File

@ -0,0 +1,529 @@
package com.zioinfo.kintex.auction;
import com.zioinfo.kintex.auction.dto.AuctionDtos.*;
import com.zioinfo.kintex.auth.EventRole;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* M15 옥션 서비스 목록·상세·응찰·낙찰·발주자 비교·업체 대시보드.
*
* <p><b>봉인 입찰 보안(서버 강제)</b>:
* <ul>
* <li>마감 상세/순위 응답은 입찰가 현재 최저가(금액만, 업체 비식별) 참여 업체 · 순위만 노출.
* 타사 입찰가·업체명은 절대 반환하지 않는다({@link #buildRanking}).</li>
* <li> 견적 공개({@link #awardView}) <b>마감 + 발주자</b> 가능.</li>
* <li>응찰은 <b>등록업체(company.registered=true)</b> + (초대 옥션이면) 초대된 업체만.</li>
* </ul>
*/
@Service
public class AuctionService {
private static final int MAX_LIMIT = 500;
private final AuctionMapper mapper;
public AuctionService(AuctionMapper mapper) {
this.mapper = mapper;
}
// 목록(SCR-26)
public List<AuctionSummary> list(String eventId, int page, int size) {
int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
int offset = Math.max(page, 0) * limit;
List<Map<String, Object>> rows = mapper.listAuctions(nullIfBlank(eventId), limit, offset);
List<AuctionSummary> out = new ArrayList<>();
for (Map<String, Object> r : rows) {
out.add(toSummary(r));
}
return out;
}
// 상세·봉인 순위(SCR-27)
public AuctionDetail getDetail(String id, KintexPrincipal principal) {
Map<String, Object> r = mapper.findAuction(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
String eventId = str(r.get("eventId"));
boolean closed = bool(r.get("closed"));
boolean sealed = !closed;
String myCompanyId = contractorCompanyId(principal, eventId);
boolean orderer = isOrderer(principal, eventId);
List<Map<String, Object>> rankRows = mapper.listRanking(id);
List<RankRow> ranking = buildRanking(rankRows, myCompanyId, sealed);
Integer myRank = null;
Long myPrice = null;
for (RankRow row : ranking) {
if (row.isMe()) {
myRank = row.rank();
myPrice = row.price();
}
}
boolean registered = myCompanyId != null && contractorRegistered(principal, eventId);
boolean invitedOk = myCompanyId != null
&& (mapper.countInvites(id) == 0 || mapper.isInvited(id, myCompanyId) > 0);
boolean canBid = !closed && registered && invitedOk;
boolean canViewAward = closed && orderer;
List<String> materials = splitMaterials(r.get("materials"));
MaterialPackage pkg = materialPackage(str(r.get("materialPackageId")), materials);
return new AuctionDetail(
id, eventId, str(r.get("title")), str(r.get("category")),
mapType(str(r.get("auctionType"))), status(r),
intOr(r.get("round"), 1), intOr(r.get("dday"), 0), str(r.get("deadline")),
intOr(r.get("bidderCount"), 0), lng(r.get("lowestPrice")),
str(r.get("awardCriteria")),
new Weights(intOr(r.get("weightPrice"), 0), intOr(r.get("weightReputation"), 0),
intOr(r.get("weightDelivery"), 0)),
materials, pkg, sealed, myRank, myPrice, ranking, canBid, canViewAward,
str(r.get("awardedCompany")), lng(r.get("finalPrice")));
}
/**
* 봉인 마스킹. 노출 대상 = 응찰 + 현재 최저가(금액만, 위치 라벨). 타사는 금액·업체명 미반환.
* (마감 후에도 상세 순위는 봉인 유지 전체 공개는 발주자 award-view 전용.)
*/
private List<RankRow> buildRanking(List<Map<String, Object>> rows, String myCompanyId, boolean sealed) {
List<RankRow> out = new ArrayList<>();
int rank = 0;
for (Map<String, Object> row : rows) {
rank++;
boolean isMe = myCompanyId != null && myCompanyId.equals(str(row.get("companyId")));
boolean isLowest = rank == 1;
long total = lngPrim(row.get("total"));
// 봉인 규칙: 응찰과 최저가만 금액 공개. 외는 마스킹(업체명·금액 미반환).
boolean reveal = isMe || isLowest;
String alias = isMe ? "나의 응찰" : (isLowest ? "현재 최저가" : "업체 " + rank);
out.add(new RankRow(rank, alias, isMe, isLowest, !reveal, reveal ? total : null));
}
return out;
}
// 응찰(견적서 제출)
@Transactional
public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) {
Map<String, Object> a = mapper.findAuction(id);
if (a == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
if (bool(a.get("closed"))) {
throw new ApiException(ErrorCode.CONFLICT); // 마감 응찰 불가(409)
}
boolean hasLines = req != null && req.lines() != null && !req.lines().isEmpty();
if (req == null || (!hasLines && (req.total() == null || req.total() <= 0))) {
throw new ApiException(ErrorCode.VALIDATION);
}
String eventId = str(a.get("eventId"));
Map<String, Object> co = mapper.findContractorCompany(principal.userId(), eventId);
if (co == null) {
throw new ApiException(ErrorCode.FORBIDDEN); // 행사의 장치업체 멤버가 아님
}
if (!bool(co.get("registered"))) {
throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY); // 미등록 업체 차단(403)
}
String companyId = str(co.get("companyId"));
if (mapper.countInvites(id) > 0 && mapper.isInvited(id, companyId) == 0) {
throw new ApiException(ErrorCode.FORBIDDEN); // 초대 옥션 미초대 차단
}
// 라인아이템이 있으면 서버가 소계 재계산(신뢰 경계). 없으면 total(부가세 별도) 폴백.
long subtotal = hasLines
? req.lines().stream().mapToLong(l -> nz(l.qty()) * nz(l.unitPrice())).sum()
: req.total();
if (subtotal <= 0) {
throw new ApiException(ErrorCode.VALIDATION);
}
long vat = Math.round(subtotal * 0.1);
long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계
int leadDays = req.leadDays() == null ? 0 : req.leadDays();
String linesJson = hasLines ? toJson(req.lines()) : null;
Map<String, Object> p = new java.util.HashMap<>();
p.put("auctionId", id);
p.put("companyId", companyId);
p.put("bidderUserId", principal.userId());
p.put("round", intOr(a.get("round"), 1));
p.put("subtotal", subtotal);
p.put("vat", vat);
p.put("total", total);
p.put("leadDays", leadDays);
p.put("validUntil", nullIfBlank(req.validUntil()));
p.put("terms", req.terms());
p.put("lines", linesJson);
Map<String, Object> existing = mapper.findBid(id, companyId);
int version;
String bidId;
if (existing == null) {
bidId = "bid-" + UUID.randomUUID().toString().substring(0, 12);
p.put("id", bidId);
mapper.insertBid(p);
version = 1;
} else {
bidId = str(existing.get("id"));
mapper.updateBid(p); // version = version + 1
version = intOr(existing.get("version"), 1) + 1;
}
int myRank = mapper.lowerBidCount(id, total) + 1;
Long lowest = lng(mapper.findAuction(id).get("lowestPrice"));
return new BidResult(bidId, intOr(a.get("round"), 1), total, vat, total, version, myRank, lowest);
}
// 낙찰(Award)
@Transactional
public AwardResult award(String id, KintexPrincipal principal, AwardRequest req) {
Map<String, Object> a = mapper.findAuction(id);
if (a == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
if (!isOrderer(principal, str(a.get("eventId")))) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
if (!bool(a.get("closed"))) {
throw new ApiException(ErrorCode.CONFLICT); // 마감 후에만 낙찰 가능(409)
}
if (req == null || req.bidId() == null || req.reason() == null || req.reason().isBlank()) {
throw new ApiException(ErrorCode.VALIDATION); // 사유 필수
}
String bidAuction = mapper.findAuctionIdOfBid(req.bidId());
if (bidAuction == null || !bidAuction.equals(id)) {
throw new ApiException(ErrorCode.VALIDATION); // 옥션의 응찰이 아님
}
if (mapper.findAward(id) != null) {
throw new ApiException(ErrorCode.CONFLICT); // 이미 낙찰됨
}
String awardId = "aw-" + UUID.randomUUID().toString().substring(0, 12);
Map<String, Object> p = new java.util.HashMap<>();
p.put("id", awardId);
p.put("auctionId", id);
p.put("bidId", req.bidId());
p.put("reason", req.reason());
p.put("awardedBy", principal.userId());
mapper.insertAward(p);
Map<String, Object> aw = mapper.findAward(id);
return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt")));
}
// 발주자 비교(SCR-29 · 마감 봉인 해제)
public AwardView awardView(String id, KintexPrincipal principal) {
Map<String, Object> a = mapper.findAuction(id);
if (a == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
if (!isOrderer(principal, str(a.get("eventId")))) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
if (!bool(a.get("closed"))) {
throw new ApiException(ErrorCode.FORBIDDEN); // 마감 봉인 유지 발주자도 전체 열람 불가
}
List<Map<String, Object>> rows = mapper.listQuotes(id);
String criteria = str(a.get("awardCriteria"));
Weights w = new Weights(intOr(a.get("weightPrice"), 0), intOr(a.get("weightReputation"), 0),
intOr(a.get("weightDelivery"), 0));
long minTotal = Long.MAX_VALUE;
int minLead = Integer.MAX_VALUE;
for (Map<String, Object> r : rows) {
minTotal = Math.min(minTotal, lngPrim(r.get("total")));
minLead = Math.min(minLead, intOr(r.get("leadDays"), 0));
}
List<Quote> quotes = new ArrayList<>();
double bestScore = -1;
int bestIdx = -1;
// 1차: 스코어 계산
double[] scores = new double[rows.size()];
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> r = rows.get(i);
long total = lngPrim(r.get("total"));
int lead = intOr(r.get("leadDays"), 0);
double rating = dbl(r.get("rating"), 4.0);
double priceScore = minTotal > 0 ? (double) minTotal / total * 100.0 : 100.0;
double repScore = rating / 5.0 * 100.0;
double leadScore = lead > 0 && minLead > 0 ? (double) minLead / lead * 100.0 : 100.0;
double score;
if ("lowest".equals(criteria)) {
score = priceScore;
} else {
int denom = Math.max(w.price() + w.reputation() + w.delivery(), 1);
score = (priceScore * w.price() + repScore * w.reputation() + leadScore * w.delivery()) / denom;
}
scores[i] = round1(score);
if (scores[i] > bestScore) {
bestScore = scores[i];
bestIdx = i;
}
}
// 2차: DTO 조립
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> r = rows.get(i);
long total = lngPrim(r.get("total"));
int lead = intOr(r.get("leadDays"), 0);
boolean rec = i == bestIdx;
double delta = minTotal > 0 ? round1((double) (total - minTotal) / minTotal * 100.0) : 0.0;
quotes.add(new Quote(
str(r.get("bidId")), str(r.get("companyId")), str(r.get("companyName")),
"종합 " + (i + 1) + "" + (rec ? " (추천)" : ""), rec,
total, delta, total == minTotal, lead, lead == minLead && lead > 0,
round1(dbl(r.get("rating"), 4.0)), scores[i]));
}
List<RiskBar> riskBars = riskBars(bestIdx >= 0 ? rows.get(bestIdx) : null);
Map<String, Object> awRow = mapper.findAward(id);
AwardResult awarded = awRow == null ? null
: new AwardResult(str(awRow.get("id")), str(awRow.get("bidId")),
str(awRow.get("companyName")), str(awRow.get("awardedAt")));
return new AwardView(id, str(a.get("title")), rows.size(), criteria, w, quotes, riskBars, awarded);
}
private List<RiskBar> riskBars(Map<String, Object> recommended) {
double rating = recommended == null ? 4.0 : dbl(recommended.get("rating"), 4.0);
double claim = recommended == null ? 2.0 : dbl(recommended.get("claimRate"), 2.0);
double delayRisk = round1(Math.max(claim, 0));
double budgetRisk = round1(Math.max(0, (5.0 - rating) * 8.0));
double quality = round1(rating / 5.0 * 100.0);
List<RiskBar> bars = new ArrayList<>();
bars.add(new RiskBar("시공 지연 확률", delayRisk, delayRisk <= 5 ? "success" : "ai"));
bars.add(new RiskBar("예산 초과 위험", budgetRisk, budgetRisk <= 10 ? "success" : "ai"));
bars.add(new RiskBar("자재 품질 신뢰도", quality, quality >= 90 ? "success" : "ai"));
return bars;
}
// 개설(SCR-26)
@Transactional
public AuctionSummary create(KintexPrincipal principal, CreateAuctionRequest req) {
if (req == null || req.eventId() == null || req.eventId().isBlank()
|| req.title() == null || req.title().isBlank()
|| req.deadline() == null || req.deadline().isBlank()) {
throw new ApiException(ErrorCode.VALIDATION);
}
if (!isOrderer(principal, req.eventId())) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
String id = "AUC-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
Weights w = req.weights() != null ? req.weights() : new Weights(60, 25, 15);
List<String> mats = req.materials() == null ? List.of() : req.materials();
Map<String, Object> p = new java.util.HashMap<>();
p.put("id", id);
p.put("eventId", req.eventId());
p.put("title", req.title());
p.put("category", req.category() == null ? "전시디자인설치" : req.category());
p.put("auctionType", "RFQ".equalsIgnoreCase(req.type()) ? "rfq" : "reverse");
p.put("awardCriteria", "lowest".equals(req.awardCriteria()) ? "lowest" : "comprehensive");
p.put("weightPrice", w.price());
p.put("weightReputation", w.reputation());
p.put("weightDelivery", w.delivery());
p.put("round", req.round() == null ? 1 : req.round());
p.put("deadline", req.deadline());
p.put("materialPackageId", nullIfBlank(req.materialPackageId()));
p.put("materials", String.join(",", mats));
p.put("accent", "primary");
p.put("createdBy", principal.userId());
mapper.insertAuction(p);
// 초대: 등록업체(registered=true) 통과 미등록 id는 서버가 걸러낸다(게이트 불변).
if (req.invitedCompanyIds() != null && !req.invitedCompanyIds().isEmpty()) {
List<String> registeredIds = mapper.filterRegistered(req.invitedCompanyIds());
for (String cid : registeredIds) {
Map<String, Object> inv = new java.util.HashMap<>();
inv.put("id", "ai-" + UUID.randomUUID().toString().substring(0, 12));
inv.put("auctionId", id);
inv.put("companyId", cid);
mapper.insertInvite(inv);
}
}
return toSummary(mapper.findAuction(id));
}
// 업체 포털 대시보드(SCR-38)
public ContractorDashboard contractorDashboard(KintexPrincipal principal) {
Map<String, Object> co = mapper.findUserCompany(principal.userId());
if (co == null) {
return new ContractorDashboard(null, List.of(
new Kpi("수주 부스", 0, "normal"), new Kpi("진행 옥션", 0, "normal"),
new Kpi("응찰 대기", 0, "normal"), new Kpi("시공 진행", 0, "success"),
new Kpi("마감 임박", 0, "error")),
List.of(), List.of(), List.of());
}
String companyId = str(co.get("companyId"));
String companyName = str(co.get("companyName"));
List<Map<String, Object>> bidRows = mapper.listMyBids(companyId);
List<MyBid> myBids = new ArrayList<>();
int liveCount = 0, imminent = 0;
for (Map<String, Object> b : bidRows) {
long myPrice = lngPrim(b.get("myPrice"));
int dday = intOr(b.get("dday"), 0);
boolean closed = bool(b.get("closed"));
int rank = mapper.lowerBidCount(str(b.get("auctionId")), myPrice) + 1;
myBids.add(new MyBid(str(b.get("auctionId")), str(b.get("auctionTitle")), myPrice, rank, dday));
if (!closed) {
liveCount++;
if (dday >= 0 && dday <= 1) imminent++;
}
}
List<Map<String, Object>> awRows = mapper.listMyAwardedBooths(companyId);
List<AwardedBooth> booths = new ArrayList<>();
for (Map<String, Object> b : awRows) {
String title = str(b.get("auctionTitle"));
booths.add(new AwardedBooth(str(b.get("auctionId")), str(b.get("eventName")),
title, boothCode(title), str(b.get("category")), null, "시공"));
}
List<FeedItem> feed = new ArrayList<>();
for (AwardedBooth b : booths) {
feed.add(new FeedItem("낙찰 확정: " + b.name(), b.event() + " · 발주 전환 대기", "ok"));
}
for (MyBid b : myBids) {
if (b.myRank() != null && b.myRank() == 1) {
feed.add(new FeedItem("현재 1위 응찰: " + b.auctionTitle(), "D-" + Math.max(b.dday(), 0), "info"));
}
}
List<Kpi> kpis = List.of(
new Kpi("수주 부스", booths.size(), "normal"),
new Kpi("진행 옥션", liveCount, "normal"),
new Kpi("응찰", myBids.size(), "normal"),
new Kpi("시공 진행", booths.size(), "success"),
new Kpi("마감 임박", imminent, imminent > 0 ? "error" : "normal"));
return new ContractorDashboard(companyName, kpis, booths, myBids, feed);
}
// 권한/매핑 헬퍼
private boolean isOrderer(KintexPrincipal principal, String eventId) {
if (principal == null) return false;
if (principal.hallManager()) return true;
EventRole role = principal.roleFor(eventId);
return role == EventRole.ORGANIZER || role == EventRole.EXHIBITOR || role == EventRole.HALL_MANAGER;
}
private String contractorCompanyId(KintexPrincipal principal, String eventId) {
if (principal == null) return null;
Map<String, Object> co = mapper.findContractorCompany(principal.userId(), eventId);
return co == null ? null : str(co.get("companyId"));
}
private boolean contractorRegistered(KintexPrincipal principal, String eventId) {
Map<String, Object> co = mapper.findContractorCompany(principal.userId(), eventId);
return co != null && bool(co.get("registered"));
}
private AuctionSummary toSummary(Map<String, Object> r) {
return new AuctionSummary(
str(r.get("id")), str(r.get("eventId")), str(r.get("title")), str(r.get("category")),
mapType(str(r.get("auctionType"))), status(r), intOr(r.get("round"), 1), intOr(r.get("dday"), 0),
intOr(r.get("bidderCount"), 0), lng(r.get("lowestPrice")), str(r.get("awardedCompany")),
lng(r.get("finalPrice")), splitMaterials(r.get("materials")), str(r.get("accent")));
}
/** 표시 상태: 낙찰됨→정산중, 마감경과·미낙찰→마감, 그 외→진행중. */
private String status(Map<String, Object> r) {
if (r.get("awardBidId") != null) return "정산중";
if (bool(r.get("closed"))) return "마감";
return "진행중";
}
private String mapType(String t) {
return "rfq".equalsIgnoreCase(t) ? "RFQ" : "역경매";
}
private MaterialPackage materialPackage(String pkgId, List<String> mats) {
// 실제 URL M2~M5 자료 스토리지 연동(후속). 현재는 존재 여부만 표기(없으면 null 플레이스홀더).
return new MaterialPackage(
mats.contains("layout") ? placeholder(pkgId, "layout") : null,
mats.contains("design") ? placeholder(pkgId, "design") : null,
mats.contains("boq") ? placeholder(pkgId, "boq") : null,
mats.contains("aiimage") ? placeholder(pkgId, "aiimage") : null);
}
/** 제목에서 부스 코드 추출(예: "A-102 독립부스 시공" → "A-102"). 없으면 앞 단어. */
private String boothCode(String title) {
if (title == null || title.isBlank()) return "";
java.util.regex.Matcher m = java.util.regex.Pattern.compile("([A-Za-z]-?\\d{1,4})").matcher(title);
if (m.find()) return m.group(1);
return title.split("\\s+")[0];
}
private String placeholder(String pkgId, String kind) {
return pkgId == null ? null : "/api/materials/" + pkgId + "/" + kind;
}
private List<String> splitMaterials(Object o) {
String s = str(o);
if (s == null || s.isBlank()) return List.of();
return Arrays.stream(s.split(",")).map(String::trim).filter(x -> !x.isEmpty()).toList();
}
private static String nullIfBlank(String s) {
return s == null || s.isBlank() ? null : s;
}
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 Integer intOr(Object o, int def) {
if (o == null) return def;
if (o instanceof Number n) return n.intValue();
try {
return Integer.valueOf(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;
}
}
private static long lngPrim(Object o) {
Long v = lng(o);
return v == null ? 0L : v;
}
private static double dbl(Object o, double def) {
if (o == null) return def;
if (o instanceof Number n) return n.doubleValue();
try {
return Double.parseDouble(String.valueOf(o));
} catch (NumberFormatException e) {
return def;
}
}
private static double round1(double v) {
return Math.round(v * 10.0) / 10.0;
}
}

View File

@ -0,0 +1,34 @@
package com.zioinfo.kintex.auction;
import com.zioinfo.kintex.auction.dto.AuctionDtos.ContractorDashboard;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 업체 포털(SCR-38) 장치·공사업체 수주 부스·진행 옥션· 응찰 요약.
* 자사 응찰 금액만 반환(타사 금액 비노출 봉인 규칙 준수).
*/
@RestController
@RequestMapping("/api/contractor")
public class ContractorController {
private final AuctionService service;
private final EventAccessGuard guard;
public ContractorController(AuctionService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET /api/contractor/dashboard — 내 업체 대시보드(소속 없으면 빈 지표). */
@GetMapping("/dashboard")
public ApiResponse<ContractorDashboard> dashboard(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
return ApiResponse.ok(service.contractorDashboard(principal));
}
}

View File

@ -0,0 +1,64 @@
package com.zioinfo.kintex.auction;
import com.zioinfo.kintex.auction.dto.AuctionDtos.QuoteLine;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import java.util.List;
/**
* 견적서 금액 서버 재계산(순수 함수 단위 테스트 대상).
*
* <p>클라이언트가 보낸 금액을 신뢰하지 않고 서버가 라인아이템(수량×단가)으로 소계를 재계산한다.
* <ul>
* <li><b>subtotal(소계)</b> = Σ(수량 × 단가) 부가세 제외. 옥션 <b>경쟁/순위 금액</b>이며 {@code bid.total} 저장된다
* (기존 시드·순위 로직과 정합: 순위·최저가는 부가세 제외 소계 기준).</li>
* <li><b>vat(부가세)</b> = round(subtotal × 10%).</li>
* <li><b>grandTotal(총액)</b> = subtotal + vat 화면 표기용 부가세 포함 총액.</li>
* </ul>
* 음수 수량·단가, 소계 0 이하는 검증 오류(400).
*/
public final class QuotationCalc {
public static final double VAT_RATE = 0.1;
private QuotationCalc() {
}
/** subtotal=경쟁 금액(부가세 별도), vat=10%, grandTotal=subtotal+vat. */
public record Totals(long subtotal, long vat, long grandTotal) {
}
/** 라인아이템 합산 재계산. */
public static Totals fromLines(List<QuoteLine> lines) {
if (lines == null || lines.isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION);
}
long subtotal = 0;
for (QuoteLine l : lines) {
long qty = l.qty() == null ? 0 : l.qty();
long unit = l.unitPrice() == null ? 0 : l.unitPrice();
if (qty < 0 || unit < 0) {
throw new ApiException(ErrorCode.VALIDATION);
}
subtotal += qty * unit;
}
return finish(subtotal);
}
/** 라인아이템 미전송 시 폴백 — total 은 경쟁 금액(부가세 별도)으로 간주. */
public static Totals fromTotal(Long total) {
if (total == null) {
throw new ApiException(ErrorCode.VALIDATION);
}
return finish(total);
}
private static Totals finish(long subtotal) {
if (subtotal <= 0) {
throw new ApiException(ErrorCode.VALIDATION);
}
long vat = Math.round(subtotal * VAT_RATE);
return new Totals(subtotal, vat, subtotal + vat);
}
}

View File

@ -0,0 +1,120 @@
package com.zioinfo.kintex.auction.dto;
import java.util.List;
/**
* M15 옥션 응답/요청 DTO 모음(프론트 auctionApi.ts 계약 정본과 정합).
* <p>봉인 입찰 보안: 응찰자 {@link RankRow} 마감 타사 금액을 {@code price=null,priceMasked=true}
* 마스킹하며 업체명을 노출하지 않는다. 전체 공개({@link AwardView}) 마감 발주자 전용.
*/
public final class AuctionDtos {
private AuctionDtos() {
}
/** 목록·카드(SCR-26). status/type 는 화면 표시 문자열(진행중|마감|정산중 / 역경매|RFQ). */
public record AuctionSummary(
String id, String eventId, String title, String category,
String type, String status, int round, int dday,
int bidderCount, Long lowestPrice, String awardedCompany, Long finalPrice,
List<String> materials, String accent) {
}
/** 종합평가 가중치. */
public record Weights(int price, int reputation, int delivery) {
}
/** M2~M5 AI 자료 패키지 참조 URL(없으면 null → 플레이스홀더). */
public record MaterialPackage(String layoutUrl, String designUrl, String boqUrl, String aiImageUrl) {
}
/**
* 실시간 순위 (응찰자 · 봉인). priceMasked=true 이면 price null(타사 비공개).
* alias 위치 라벨(업체명 아님) 업체 식별 불가.
*/
public record RankRow(int rank, String alias, boolean isMe, boolean isLowest,
boolean priceMasked, Long price) {
}
/** 상세(SCR-27). ranking 은 봉인 마스킹 적용본. canBid/canViewAward 로 UI 게이팅. */
public record AuctionDetail(
String id, String eventId, String title, String category, String type, String status,
int round, int dday, String deadline, int bidderCount, Long lowestPrice,
String awardCriteria, Weights weights, List<String> materials, MaterialPackage materialPackage,
boolean sealed, Integer myRank, Long myPrice,
List<RankRow> ranking, boolean canBid, boolean canViewAward,
String awardedCompany, Long finalPrice) {
}
/** 옥션 개설 요청(SCR-26). */
public record CreateAuctionRequest(
String eventId, String title, String category, String type,
String awardCriteria, Weights weights, Integer round, String deadline,
String materialPackageId, List<String> materials, List<String> invitedCompanyIds) {
}
/** 견적서 라인아이템(SCR-28) — 공종·자재·수량·단가. 금액=수량×단가는 서버 계산. */
public record QuoteLine(String trade, String material, Long qty, Long unitPrice) {
}
/**
* 응찰(견적서 제출) 요청. lines(라인아이템) 있으면 서버가 소계/부가세/총액을 재계산한다.
* lines 미전송 total(경쟁 금액, 부가세 별도)만으로 폴백. 없으면 검증 오류.
*/
public record BidRequest(Long total, Integer leadDays, String validUntil, String terms,
List<QuoteLine> lines) {
}
/**
* 응찰 결과(자사 확인용). total=경쟁 순위 금액(부가세 별도, 최저가 비교 기준),
* grandTotal=부가세 포함 총액(소계+부가세).
*/
public record BidResult(String id, int round, long subtotal, long vat, long total, long grandTotal,
int version, Integer myRank, Long lowestPrice) {
}
/** 낙찰 요청 — reason 필수. */
public record AwardRequest(String bidId, String reason) {
}
/** 낙찰 결과. */
public record AwardResult(String id, String bidId, String companyName, String awardedAt) {
}
/** 발주자 비교 컬럼(SCR-29 · 마감 후 봉인 해제). alias=업체명. */
public record Quote(
String bidId, String companyId, String alias, String subtitle, boolean recommended,
long totalPrice, double priceDeltaPct, boolean isLowestPrice,
int leadDays, boolean isShortestLead, double reputation, double score) {
}
/** AI 리스크 바(발주자 뷰). */
public record RiskBar(String label, double pct, String tone) {
}
/** 낙찰 비교·선정 화면 페이로드(마감 후 발주자 전용). */
public record AwardView(
String auctionId, String auctionTitle, int bidderCount,
String awardCriteria, Weights weights, List<Quote> quotes,
List<RiskBar> riskBars, AwardResult awarded) {
}
// 업체 포털 대시보드(SCR-38)
public record Kpi(String label, int value, String tone) {
}
public record AwardedBooth(String auctionId, String event, String name, String booth,
String client, Integer dday, String stage) {
}
public record MyBid(String auctionId, String auctionTitle, long myPrice, Integer myRank, int dday) {
}
public record FeedItem(String text, String meta, String tone) {
}
public record ContractorDashboard(
String companyName, List<Kpi> kpis, List<AwardedBooth> awardedBooths,
List<MyBid> myAuctionBids, List<FeedItem> feed) {
}
}

View File

@ -0,0 +1,82 @@
package com.zioinfo.kintex.cms;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest;
import com.zioinfo.kintex.cms.dto.CmsContentDto;
import com.zioinfo.kintex.cms.dto.CmsTranslationDto;
import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* M17 CMS 콘텐츠 API (SCR-35·37). 인증 필수.
* <ul>
* <li>GET/POST /api/cms/contents 목록/신규(초안)</li>
* <li>PATCH /api/cms/contents/&#123;id&#125;/status?value= 게시 전이(전진만·역전이 400, 승인/게시는 매니저)</li>
* <li>GET/PUT /api/cms/contents/&#123;id&#125;/translations 언어별 번역 upsert(ko/en/zh/ja)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/cms/contents")
public class CmsContentController {
private final CmsService service;
private final EventAccessGuard guard;
public CmsContentController(CmsService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<PageResponse<CmsContentDto>> list(
@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String eventId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String type,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
guard.require(principal);
return ApiResponse.ok(service.list(eventId, status, type, keyword, page, size));
}
@PostMapping
public ApiResponse<CmsContentDto> create(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody CmsContentCreateRequest req) {
guard.require(principal);
return ApiResponse.ok(service.create(principal, req));
}
/** 게시 상태 전이. value=draft|review|approved|published(전진만). 역전이/무효 → 400. */
@PatchMapping("/{id}/status")
public ApiResponse<CmsContentDto> transition(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestParam String value) {
guard.require(principal);
return ApiResponse.ok(service.transition(principal, id, value));
}
@GetMapping("/{id}/translations")
public ApiResponse<List<CmsTranslationDto>> translations(
@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.translations(id));
}
@PutMapping("/{id}/translations")
public ApiResponse<List<CmsTranslationDto>> saveTranslation(
@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@Valid @RequestBody CmsTranslationSaveRequest req) {
guard.require(principal);
return ApiResponse.ok(service.saveTranslation(id, req));
}
}

View File

@ -0,0 +1,100 @@
package com.zioinfo.kintex.cms;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/**
* M17 CMS 매퍼 콘텐츠 + 번역. Map 반환 @Select 별칭은 반드시 쌍따옴표(: {@code AS "eventId"}).
* 게시 상태(draftreviewapprovedpublished)·예약 게시(scheduled_at)·번역 상태(none/ai/reviewed).
*/
@Mapper
public interface CmsMapper {
// 콘텐츠
@Select("""
<script>
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
signage, mailing, author_name AS "authorName",
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM cms_content
<where>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="type != null and type != ''">AND content_type = #{type}</if>
<if test="keyword != null and keyword != ''">AND title ILIKE '%' || #{keyword} || '%'</if>
</where>
ORDER BY updated_at DESC, id DESC
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> findContents(Map<String, Object> q);
@Select("""
<script>
SELECT count(*) FROM cms_content
<where>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="type != null and type != ''">AND content_type = #{type}</if>
<if test="keyword != null and keyword != ''">AND title ILIKE '%' || #{keyword} || '%'</if>
</where>
</script>
""")
long countContents(Map<String, Object> q);
@Select("""
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt",
signage, mailing, author_name AS "authorName",
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM cms_content WHERE id = #{id}
""")
Map<String, Object> findContentById(@Param("id") String id);
@Insert("""
INSERT INTO cms_content (id, event_id, content_type, title, body, status, lang, author_id, author_name)
VALUES (#{id}, #{eventId}, COALESCE(#{contentType},'PAGE'), #{title}, #{body},
'draft', COALESCE(#{lang},'ko'), #{authorId}, #{authorName})
""")
int insertContent(Map<String, Object> p);
/** 상태 전이(전이 검증은 서비스). published 전이 시 published_at=now(). */
@Update("""
UPDATE cms_content
SET status = #{status},
published_at = CASE WHEN #{status} = 'published' THEN now() ELSE published_at END,
updated_at = now()
WHERE id = #{id}
""")
int updateStatus(@Param("id") String id, @Param("status") String status);
// 번역
@Select("""
SELECT content_id AS "contentId", lang, title, body, trans_status AS "transStatus",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM cms_translation
WHERE content_id = #{contentId}
ORDER BY lang
""")
List<Map<String, Object>> findTranslations(@Param("contentId") String contentId);
/** 언어별 upsert(멱등) — (content_id, lang) 유니크 충돌 시 갱신. */
@Insert("""
INSERT INTO cms_translation (id, content_id, lang, title, body, trans_status, updated_at)
VALUES (#{id}, #{contentId}, #{lang}, #{title}, #{body},
COALESCE(#{transStatus},'none'), now())
ON CONFLICT (content_id, lang) DO UPDATE
SET title = EXCLUDED.title,
body = EXCLUDED.body,
trans_status = EXCLUDED.trans_status,
updated_at = now()
""")
int upsertTranslation(Map<String, Object> p);
}

View File

@ -0,0 +1,160 @@
package com.zioinfo.kintex.cms;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest;
import com.zioinfo.kintex.cms.dto.CmsContentDto;
import com.zioinfo.kintex.cms.dto.CmsTranslationDto;
import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.system.SystemAccessGuard;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* M17 CMS 서비스 콘텐츠 게시 워크플로 + 다국어 번역.
* <p>상태 전이: draft(0)review(1)approved(2)published(3) 전진만 허용. 역전이/동일 전이 400(VALIDATION).
* approved·published 전이는 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합).
*/
@Service
public class CmsService {
private static final List<String> FLOW = List.of("draft", "review", "approved", "published");
private static final Set<String> TRANS_STATUS = Set.of("none", "ai", "reviewed");
private static final Set<String> LANGS = Set.of("ko", "en", "zh", "ja");
private final CmsMapper mapper;
private final SystemAccessGuard scope;
public CmsService(CmsMapper mapper, SystemAccessGuard scope) {
this.mapper = mapper;
this.scope = scope;
}
public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 50 : Math.min(size, 200);
Map<String, Object> q = new HashMap<>();
q.put("eventId", blankToNull(eventId));
q.put("status", blankToNull(status));
q.put("type", blankToNull(type));
q.put("keyword", blankToNull(keyword));
q.put("size", s);
q.put("offset", p * s);
List<Map<String, Object>> rows = mapper.findContents(q);
long total = mapper.countContents(q);
return PageResponse.of(rows.stream().map(CmsService::toContentDto).toList(), p, s, total);
}
public CmsContentDto get(String id) {
Map<String, Object> r = mapper.findContentById(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
return toContentDto(r);
}
@Transactional
public CmsContentDto create(KintexPrincipal principal, CmsContentCreateRequest req) {
String id = "cms-" + UUID.randomUUID().toString().substring(0, 12);
Map<String, Object> pm = new HashMap<>();
pm.put("id", id);
pm.put("eventId", blankToNull(req.eventId()));
pm.put("contentType", blankToNull(req.contentType()));
pm.put("title", req.title());
pm.put("body", req.body());
pm.put("lang", blankToNull(req.lang()));
pm.put("authorId", principal.userId());
pm.put("authorName", principal.displayName());
mapper.insertContent(pm);
return get(id);
}
/** 상태 전이 — value 는 목표 상태. 전진만 허용, 역전이/무효 값 → 400. */
@Transactional
public CmsContentDto transition(KintexPrincipal principal, String id, String value) {
Map<String, Object> r = mapper.findContentById(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
String from = String.valueOf(r.get("status"));
String to = value == null ? "" : value.trim();
int fromIdx = FLOW.indexOf(from);
int toIdx = FLOW.indexOf(to);
if (toIdx < 0) {
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 게시 상태입니다: " + to);
}
if (toIdx <= fromIdx) {
throw new ApiException(ErrorCode.VALIDATION,
"게시 워크플로는 전진만 가능합니다(" + from + "" + to + " 불가).");
}
// 승인·게시는 관리자/주최자(매니저 이상) 권한
if ("approved".equals(to) || "published".equals(to)) {
scope.requireManager(principal);
}
mapper.updateStatus(id, to);
return get(id);
}
// 번역
public List<CmsTranslationDto> translations(String contentId) {
if (mapper.findContentById(contentId) == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
return mapper.findTranslations(contentId).stream().map(CmsService::toTranslationDto).toList();
}
@Transactional
public List<CmsTranslationDto> saveTranslation(String contentId, CmsTranslationSaveRequest req) {
if (mapper.findContentById(contentId) == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
String lang = req.lang() == null ? "" : req.lang().trim();
if (!LANGS.contains(lang)) {
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 언어입니다: " + lang);
}
String st = req.transStatus() == null ? "none" : req.transStatus().trim();
if (!TRANS_STATUS.contains(st)) {
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 번역 상태입니다: " + st);
}
Map<String, Object> pm = new HashMap<>();
pm.put("id", "tr-" + UUID.randomUUID().toString().substring(0, 12));
pm.put("contentId", contentId);
pm.put("lang", lang);
pm.put("title", req.title());
pm.put("body", req.body());
pm.put("transStatus", st);
mapper.upsertTranslation(pm);
return translations(contentId);
}
// 매핑
private static CmsContentDto toContentDto(Map<String, Object> r) {
return new CmsContentDto(
str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")),
str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")),
str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")),
str(r.get("authorName")), str(r.get("publishedAt")),
str(r.get("createdAt")), str(r.get("updatedAt")));
}
private static CmsTranslationDto toTranslationDto(Map<String, Object> r) {
return new CmsTranslationDto(
str(r.get("contentId")), str(r.get("lang")), str(r.get("title")),
str(r.get("body")), str(r.get("transStatus")), str(r.get("updatedAt")));
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static boolean bool(Object o) {
return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.kintex.cms;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.MicrositeDto;
import com.zioinfo.kintex.cms.dto.MicrositeSaveRequest;
import com.zioinfo.kintex.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/**
* M17 참가업체 마이크로사이트 편집 API (SCR-36). 인증 필수.
* GET/PUT /api/exhibitors/&#123;exhibitorId&#125;/microsite 섹션 JSON·테마·SEO·상태 저장/조회.
* 공개 읽기는 {@link PublicMicrositeController}(/api/public/microsites, 무인증).
*/
@RestController
@RequestMapping("/api/exhibitors/{exhibitorId}/microsite")
public class MicrositeController {
private final MicrositeService service;
private final EventAccessGuard guard;
public MicrositeController(MicrositeService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<MicrositeDto> get(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String exhibitorId) {
guard.require(principal);
return ApiResponse.ok(service.get(exhibitorId));
}
@PutMapping
public ApiResponse<MicrositeDto> save(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String exhibitorId,
@Valid @RequestBody MicrositeSaveRequest req) {
guard.require(principal);
return ApiResponse.ok(service.save(exhibitorId, req));
}
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.kintex.cms;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Map;
/**
* M17 마이크로사이트 매퍼 참가업체별 공개 소개 페이지. Map 별칭 쌍따옴표 필수.
* sections jsonb sections::text 원문 문자열 반환(서비스에서 JsonNode 파싱).
*/
@Mapper
public interface MicrositeMapper {
@Select("""
SELECT exhibitor_id AS "exhibitorId", event_id AS "eventId", slug,
exhibitor_name AS "exhibitorName", theme, sections::text AS "sections",
seo_title AS "seoTitle", seo_meta AS "seoMeta", langs, status,
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM microsite WHERE exhibitor_id = #{exhibitorId}
""")
Map<String, Object> findByExhibitor(@Param("exhibitorId") String exhibitorId);
/** upsert(멱등) — sections 는 jsonb 캐스팅. published 전이 시 published_at=now(). */
@Insert("""
INSERT INTO microsite (exhibitor_id, event_id, slug, exhibitor_name, theme, sections,
seo_title, seo_meta, langs, status, published_at, updated_at)
VALUES (#{exhibitorId}, #{eventId}, #{slug}, #{exhibitorName}, COALESCE(#{theme},'blue'),
CAST(COALESCE(#{sectionsJson},'[]') AS jsonb), #{seoTitle}, #{seoMeta},
COALESCE(#{langs},'ko'), COALESCE(#{status},'draft'),
CASE WHEN #{status} = 'published' THEN now() ELSE NULL END, now())
ON CONFLICT (exhibitor_id) DO UPDATE
SET event_id = EXCLUDED.event_id,
slug = EXCLUDED.slug,
exhibitor_name = EXCLUDED.exhibitor_name,
theme = EXCLUDED.theme,
sections = EXCLUDED.sections,
seo_title = EXCLUDED.seo_title,
seo_meta = EXCLUDED.seo_meta,
langs = EXCLUDED.langs,
status = EXCLUDED.status,
published_at = CASE WHEN EXCLUDED.status = 'published' THEN now()
ELSE microsite.published_at END,
updated_at = now()
""")
int upsert(Map<String, Object> p);
}

View File

@ -0,0 +1,107 @@
package com.zioinfo.kintex.cms;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.zioinfo.kintex.cms.dto.MicrositeDto;
import com.zioinfo.kintex.cms.dto.MicrositeSaveRequest;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
/**
* M17 마이크로사이트 서비스 참가업체별 공개 소개 페이지(섹션 JSON·테마·SEO).
* sections jsonb 문자열 JsonNode 변환. 공개 읽기({@code publicView}) draft 여부 무관 조회(공개 P5 팀이 상태 필터).
*/
@Service
public class MicrositeService {
private final MicrositeMapper mapper;
private final ObjectMapper objectMapper;
public MicrositeService(MicrositeMapper mapper, ObjectMapper objectMapper) {
this.mapper = mapper;
this.objectMapper = objectMapper;
}
/** 조회 — 미존재 시 빈 초안(exhibitorId만) 반환(에디터가 새로 저장 가능). */
public MicrositeDto get(String exhibitorId) {
Map<String, Object> r = mapper.findByExhibitor(exhibitorId);
if (r == null) {
return new MicrositeDto(exhibitorId, null, null, null, "blue",
objectMapper.createArrayNode(), null, null, "ko", "draft", null, null);
}
return toDto(r);
}
/** 공개 읽기 — 미존재 시 404(무인증 경로). */
public MicrositeDto publicView(String exhibitorId) {
Map<String, Object> r = mapper.findByExhibitor(exhibitorId);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
return toDto(r);
}
@Transactional
public MicrositeDto save(String exhibitorId, MicrositeSaveRequest req) {
String sectionsJson = "[]";
if (req.sections() != null && !req.sections().isNull()) {
try {
sectionsJson = objectMapper.writeValueAsString(req.sections());
} catch (Exception e) {
throw new ApiException(ErrorCode.VALIDATION, "섹션 데이터 형식이 올바르지 않습니다.");
}
}
String status = req.status() == null ? "draft" : req.status().trim();
if (!"draft".equals(status) && !"published".equals(status)) {
throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 사이트 상태입니다: " + status);
}
Map<String, Object> p = new HashMap<>();
p.put("exhibitorId", exhibitorId);
p.put("eventId", blankToNull(req.eventId()));
p.put("slug", blankToNull(req.slug()));
p.put("exhibitorName", req.exhibitorName());
p.put("theme", blankToNull(req.theme()));
p.put("sectionsJson", sectionsJson);
p.put("seoTitle", req.seoTitle());
p.put("seoMeta", req.seoMeta());
p.put("langs", blankToNull(req.langs()));
p.put("status", status);
mapper.upsert(p);
return get(exhibitorId);
}
private MicrositeDto toDto(Map<String, Object> r) {
JsonNode sections = parseSections(str(r.get("sections")));
return new MicrositeDto(
str(r.get("exhibitorId")), str(r.get("eventId")), str(r.get("slug")),
str(r.get("exhibitorName")), str(r.get("theme")), sections,
str(r.get("seoTitle")), str(r.get("seoMeta")), str(r.get("langs")),
str(r.get("status")), str(r.get("publishedAt")), str(r.get("updatedAt")));
}
private JsonNode parseSections(String json) {
if (json == null || json.isBlank()) {
return objectMapper.createArrayNode();
}
try {
JsonNode node = objectMapper.readTree(json);
return node == null ? objectMapper.createArrayNode() : node;
} catch (Exception e) {
ArrayNode empty = objectMapper.createArrayNode();
return empty;
}
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.kintex.cms;
import com.zioinfo.kintex.cms.dto.MicrositeDto;
import com.zioinfo.kintex.common.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 공개 마이크로사이트 조회 (일반 대중·관람객·SEO). 무인증 {@code /api/public/**} permitAll(SecurityConfig).
* GET /api/public/microsites/&#123;exhibitorId&#125; MicrositeDto(섹션·테마·SEO). 미존재 404.
* <p>공개 P5 렌더 (후속) slug 라우팅/OG/사이트맵을 계약 위에 배선한다(impl_cms.md §공개 계약).
*/
@RestController
@RequestMapping("/api/public/microsites")
public class PublicMicrositeController {
private final MicrositeService service;
public PublicMicrositeController(MicrositeService service) {
this.service = service;
}
@GetMapping("/{exhibitorId}")
public ApiResponse<MicrositeDto> view(@PathVariable String exhibitorId) {
return ApiResponse.ok(service.publicView(exhibitorId));
}
}

View File

@ -0,0 +1,12 @@
package com.zioinfo.kintex.cms.dto;
import jakarta.validation.constraints.NotBlank;
/** CMS 콘텐츠 신규(초안) 생성 요청. 생성 시 status=draft 고정(서버 권위). */
public record CmsContentCreateRequest(
String eventId,
String contentType,
@NotBlank String title,
String body,
String lang) {
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.kintex.cms.dto;
/** CMS 콘텐츠 항목 — 게시 워크플로(SCR-35). status: draft|review|approved|published. */
public record CmsContentDto(
String id,
String eventId,
String contentType,
String title,
String body,
String status,
String lang,
String scheduledAt,
boolean signage,
boolean mailing,
String authorName,
String publishedAt,
String createdAt,
String updatedAt) {
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.kintex.cms.dto;
/** 콘텐츠×언어 번역본(SCR-37). transStatus: none|ai|reviewed. */
public record CmsTranslationDto(
String contentId,
String lang,
String title,
String body,
String transStatus,
String updatedAt) {
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.kintex.cms.dto;
import jakarta.validation.constraints.NotBlank;
/** 번역 upsert 요청(언어별). transStatus 미지정 시 none. */
public record CmsTranslationSaveRequest(
@NotBlank String lang,
String title,
String body,
String transStatus) {
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.kintex.cms.dto;
import com.fasterxml.jackson.databind.JsonNode;
/** 참가업체 마이크로사이트(SCR-36 / 공개 뷰 SCR-P5). sections 는 JSON 배열 원문. */
public record MicrositeDto(
String exhibitorId,
String eventId,
String slug,
String exhibitorName,
String theme,
JsonNode sections,
String seoTitle,
String seoMeta,
String langs,
String status,
String publishedAt,
String updatedAt) {
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.kintex.cms.dto;
import com.fasterxml.jackson.databind.JsonNode;
/** 마이크로사이트 저장 요청 — 섹션/테마/SEO/상태. sections 는 JSON 배열. */
public record MicrositeSaveRequest(
String eventId,
String slug,
String exhibitorName,
String theme,
JsonNode sections,
String seoTitle,
String seoMeta,
String langs,
String status) {
}

View File

@ -0,0 +1,70 @@
package com.zioinfo.kintex.document;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.document.dto.DocumentReviewDto;
import com.zioinfo.kintex.document.dto.DocumentTransitionRequest;
import com.zioinfo.kintex.document.dto.MilestoneDto;
import com.zioinfo.kintex.document.dto.RequiredDocumentDto;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* M6 서류·마일스톤 API (SCR-22/23). 행사 RBAC 가드.
* HWP/PDF 렌더(파일 ) 이번 스코프 제외 엔드포인트 미노출.
*/
@RestController
@RequestMapping("/api/events/{eventId}")
public class DocumentController {
private final DocumentService service;
private final EventAccessGuard guard;
public DocumentController(DocumentService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET /milestones — 전시 마일스톤 진행 노드. */
@GetMapping("/milestones")
public ApiResponse<List<MilestoneDto>> milestones(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.getMilestones(eventId));
}
/** GET /documents — 신고서류 체크리스트. */
@GetMapping("/documents")
public ApiResponse<List<RequiredDocumentDto>> documents(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.getDocuments(eventId));
}
/** GET /documents/review — AI 서류 검수 + 전체 공정률. */
@GetMapping("/documents/review")
public ApiResponse<DocumentReviewDto> review(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.getReview(eventId));
}
/** POST /documents/{docType} — 상태 전이(임시저장 save / 제출 submit). */
@PostMapping("/documents/{docType}")
public ApiResponse<RequiredDocumentDto> transition(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String docType,
@RequestBody(required = false) DocumentTransitionRequest request) {
guard.requireEventAccess(principal, eventId);
String action = request == null ? null : request.action();
return ApiResponse.ok(service.transition(eventId, docType, action));
}
}

View File

@ -0,0 +1,73 @@
package com.zioinfo.kintex.document;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/**
* M6 서류·마일스톤 매퍼 event_milestone·required_document·document_review_issue read + 상태 전이 update.
* camelCase 별칭은 반드시 쌍따옴표(PG 소문자 폴딩 함정) WORK_STATUS §7.
*/
@Mapper
public interface DocumentMapper {
/** 행사 마일스톤(정렬 순). */
@Select("""
SELECT label,
state,
to_char(due_date, 'YYYY-MM-DD') AS "dueDate",
sort_order AS "sortOrder"
FROM event_milestone
WHERE event_id = #{eventId}
ORDER BY sort_order, due_date NULLS LAST
""")
List<Map<String, Object>> findMilestones(@Param("eventId") String eventId);
/** 신고서류 체크리스트(정렬 순). */
@Select("""
SELECT doc_type AS "docType",
name,
status,
to_char(due_date, 'YYYY-MM-DD') AS "dueDate",
sort_order AS "sortOrder"
FROM required_document
WHERE event_id = #{eventId}
ORDER BY sort_order, doc_type
""")
List<Map<String, Object>> findDocuments(@Param("eventId") String eventId);
/** 단일 서류(상태 전이 대상 존재 확인). 없으면 null. */
@Select("""
SELECT doc_type AS "docType",
name,
status,
to_char(due_date, 'YYYY-MM-DD') AS "dueDate",
sort_order AS "sortOrder"
FROM required_document
WHERE event_id = #{eventId} AND doc_type = #{docType}
""")
Map<String, Object> findDocument(@Param("eventId") String eventId, @Param("docType") String docType);
/** AI 검수 이슈(정렬 순). */
@Select("""
SELECT tone, title, description, sort_order AS "sortOrder"
FROM document_review_issue
WHERE event_id = #{eventId}
ORDER BY sort_order, id
""")
List<Map<String, Object>> findReviewIssues(@Param("eventId") String eventId);
/** 서류 상태 전이(임시저장/제출). 영향 행수 반환. */
@Update("""
UPDATE required_document
SET status = #{status}, updated_at = now()
WHERE event_id = #{eventId} AND doc_type = #{docType}
""")
int updateStatus(@Param("eventId") String eventId,
@Param("docType") String docType,
@Param("status") String status);
}

View File

@ -0,0 +1,141 @@
package com.zioinfo.kintex.document;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.document.dto.DocumentReviewDto;
import com.zioinfo.kintex.document.dto.MilestoneDto;
import com.zioinfo.kintex.document.dto.RequiredDocumentDto;
import com.zioinfo.kintex.document.dto.ReviewIssueDto;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* M6 서류·마일스톤 서비스 테이블 집계로 마일스톤·서류·검수·전이를 조립.
* dday·sub·progressPct 저장값에서 파생(원천 컬럼 최소화). 상태 전이는 화이트리스트 검증.
*/
@Service
public class DocumentService {
private final DocumentMapper mapper;
public DocumentService(DocumentMapper mapper) {
this.mapper = mapper;
}
public List<MilestoneDto> getMilestones(String eventId) {
List<MilestoneDto> out = new ArrayList<>();
for (Map<String, Object> r : mapper.findMilestones(eventId)) {
String state = str(r.get("state"));
out.add(new MilestoneDto(str(r.get("label")), subLabel(state), state, str(r.get("dueDate"))));
}
return out;
}
public List<RequiredDocumentDto> getDocuments(String eventId) {
LocalDate today = LocalDate.now();
List<RequiredDocumentDto> out = new ArrayList<>();
for (Map<String, Object> r : mapper.findDocuments(eventId)) {
out.add(toDoc(r, today));
}
return out;
}
public DocumentReviewDto getReview(String eventId) {
List<ReviewIssueDto> issues = new ArrayList<>();
for (Map<String, Object> r : mapper.findReviewIssues(eventId)) {
issues.add(new ReviewIssueDto(str(r.get("tone")), str(r.get("title")), str(r.get("description"))));
}
return new DocumentReviewDto(issues, progressPct(mapper.findDocuments(eventId)));
}
/**
* 서류 상태 전이(SCR-23 임시저장/제출). action=savedraft, submitsubmitted.
* 대상 서류 미존재 404. 잘못된 action 400.
*/
public RequiredDocumentDto transition(String eventId, String docType, String action) {
String target = switch (action == null ? "" : action.trim().toLowerCase()) {
case "save" -> "draft";
case "submit" -> "submitted";
default -> null;
};
if (target == null) {
throw new ApiException(ErrorCode.VALIDATION, "action 은 save 또는 submit 이어야 합니다.");
}
if (mapper.findDocument(eventId, docType) == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "해당 신고서류를 찾을 수 없습니다.");
}
mapper.updateStatus(eventId, docType, target);
Map<String, Object> updated = mapper.findDocument(eventId, docType);
return toDoc(updated, LocalDate.now());
}
// 파생
private RequiredDocumentDto toDoc(Map<String, Object> r, LocalDate today) {
String status = str(r.get("status"));
String dueDate = str(r.get("dueDate"));
return new RequiredDocumentDto(
str(r.get("docType")), str(r.get("name")), status, dueDate,
dday(dueDate, status, today), intVal(r.get("sortOrder")));
}
/** 준비중(pending) 제외한 서류 진척률(%). */
private static int progressPct(List<Map<String, Object>> docs) {
if (docs == null || docs.isEmpty()) {
return 0;
}
long done = docs.stream().filter(d -> !"pending".equals(String.valueOf(d.get("status")))).count();
return (int) Math.round(done * 100.0 / docs.size());
}
/** 진행 중(pending 아님) 서류에 한해 due_date 까지 남은 일수(과거면 null). */
private static Integer dday(String dueDate, String status, LocalDate today) {
if (dueDate == null || "pending".equals(status) || "approved".equals(status)) {
return null;
}
LocalDate due = parseDate(dueDate);
if (due == null) {
return null;
}
long days = ChronoUnit.DAYS.between(today, due);
return days < 0 ? null : (int) days;
}
private static String subLabel(String state) {
return switch (state == null ? "" : state) {
case "done" -> "완료";
case "active" -> "진행중";
default -> "대기";
};
}
private static LocalDate parseDate(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s);
} catch (DateTimeParseException e) {
return null;
}
}
private static 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;
}
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.kintex.document.dto;
import java.util.List;
/**
* AI 서류 검수 결과 + 전체 공정률 (SCR-22 우측 패널).
* progressPct 준비중(pending) 제외한 서류 진척률(round). issues 규정 룰셋 기반 파생 이슈.
*/
public record DocumentReviewDto(
List<ReviewIssueDto> issues,
int progressPct
) {
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.kintex.document.dto;
/**
* 서류 상태 전이 요청 (SCR-23 임시저장/제출).
* action: savedraft, submitsubmitted. 값은 400.
*/
public record DocumentTransitionRequest(
String action
) {
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.kintex.document.dto;
/**
* 전시 마일스톤 노드 (SCR-22 상단 진행 ).
* state: done|active|todo. sub state 파생 한글 라벨(완료/진행중/대기).
*/
public record MilestoneDto(
String label,
String sub,
String state,
String dueDate
) {
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.kintex.document.dto;
/**
* 신고서류 체크리스트 항목 (SCR-22 좌측 리스트).
* status: pending|draft|submitted|approved|rejected. dday due_date-오늘(미래일 때만, 없으면 null).
*/
public record RequiredDocumentDto(
String docType,
String name,
String status,
String dueDate,
Integer dday,
int sortOrder
) {
}

View File

@ -0,0 +1,9 @@
package com.zioinfo.kintex.document.dto;
/** AI 서류 검수 이슈 (SCR-22 우측 카드). tone: warn|info. */
public record ReviewIssueDto(
String tone,
String title,
String description
) {
}

View File

@ -0,0 +1,61 @@
package com.zioinfo.kintex.logistics;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.logistics.dto.DockBoardDto;
import com.zioinfo.kintex.logistics.dto.DockForecastDto;
import com.zioinfo.kintex.logistics.dto.DockGridReservationDto;
import com.zioinfo.kintex.logistics.dto.DockReservationRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* M8 반입/반출 도크 슬롯 예약 API (SCR-25). 행사 RBAC 가드. 슬롯 중복은 409 CONFLICT.
*/
@RestController
@RequestMapping("/api/events/{eventId}")
public class LogisticsController {
private final LogisticsService service;
private final EventAccessGuard guard;
public LogisticsController(LogisticsService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET /docks?direction=in|out&date=YYYY-MM-DD — 도크 현황판(그리드). */
@GetMapping("/docks")
public ApiResponse<DockBoardDto> docks(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(required = false, defaultValue = "in") String direction,
@RequestParam(required = false) String date) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.getBoard(eventId, direction, date));
}
/** POST /dock-reservations — 슬롯 예약(중복 시 409). */
@PostMapping("/dock-reservations")
public ApiResponse<DockGridReservationDto> reserve(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestBody DockReservationRequest request) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.createReservation(eventId, request));
}
/** GET /dock-forecast?date=YYYY-MM-DD — 철거일 대기열 예측(단순 시간대 집계, 부재 시 폴백). */
@GetMapping("/dock-forecast")
public ApiResponse<DockForecastDto> forecast(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(required = false) String date) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.getForecast(eventId, date));
}
}

View File

@ -0,0 +1,106 @@
package com.zioinfo.kintex.logistics;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* M8 반입/반출 도크 매퍼 dock·dock_reservation. camelCase 별칭은 쌍따옴표(PG 소문자 폴딩 함정).
*/
@Mapper
public interface LogisticsMapper {
/** 행사 배정 홀에 속한 도크 목록(그리드 행 순서). */
@Select("""
SELECT d.id,
d.dock_no AS "dockNo",
d.label,
d.heavy_priority AS "heavyPriority"
FROM dock d
JOIN hall_assignment ha ON ha.hall_id = d.hall_id
WHERE ha.event_id = #{eventId}
ORDER BY d.hall_id, d.dock_no
""")
List<Map<String, Object>> findDocks(@Param("eventId") String eventId);
/** 방향/일자 기준 예약 목록. */
@Select("""
SELECT id,
dock_id AS "dockId",
start_hour AS "startHour",
span,
company_name AS "companyName",
vehicle_weight AS "vehicleWeight",
tone
FROM dock_reservation
WHERE event_id = #{eventId}
AND direction = #{direction}
AND reserve_date = CAST(#{date} AS date)
ORDER BY dock_id, start_hour
""")
List<Map<String, Object>> findReservations(@Param("eventId") String eventId,
@Param("direction") String direction,
@Param("date") String date);
/** 방향 기준 최근 예약 일자(조회 기본값 해소). 없으면 null. */
@Select("""
SELECT to_char(min(reserve_date), 'YYYY-MM-DD')
FROM dock_reservation
WHERE event_id = #{eventId} AND direction = #{direction}
""")
String findDefaultDate(@Param("eventId") String eventId, @Param("direction") String direction);
/** 같은 도크·일자·방향에서 [startHour, endHour) 와 겹치는 예약 수(0=충돌 없음). */
@Select("""
SELECT count(*)
FROM dock_reservation
WHERE event_id = #{eventId}
AND dock_id = #{dockId}
AND direction = #{direction}
AND reserve_date = CAST(#{date} AS date)
AND start_hour < #{endHour}
AND (start_hour + span) > #{startHour}
""")
int countOverlaps(@Param("eventId") String eventId,
@Param("dockId") String dockId,
@Param("direction") String direction,
@Param("date") String date,
@Param("startHour") int startHour,
@Param("endHour") int endHour);
/** 일자 기준(방향 무관) 예약 점유 — 대기열 예측 집계용. */
@Select("""
SELECT start_hour AS "startHour", span
FROM dock_reservation
WHERE event_id = #{eventId} AND reserve_date = CAST(#{date} AS date)
""")
List<Map<String, Object>> findOccupancyByDate(@Param("eventId") String eventId,
@Param("date") String date);
@Insert("""
INSERT INTO dock_reservation
(id, event_id, dock_id, direction, reserve_date, start_hour, span,
company_name, vehicle_no, vehicle_weight, item, forklift, tone)
VALUES
(#{id}, #{eventId}, #{dockId}, #{direction}, CAST(#{date} AS date), #{startHour}, #{span},
#{companyName}, #{vehicleNo}, #{vehicleWeight}, #{item}, #{forklift}, #{tone})
""")
int insertReservation(Map<String, Object> p);
@Select("""
SELECT id,
dock_id AS "dockId",
start_hour AS "startHour",
span,
company_name AS "companyName",
vehicle_weight AS "vehicleWeight",
tone
FROM dock_reservation
WHERE id = #{id}
""")
Map<String, Object> findReservationById(@Param("id") String id);
}

View File

@ -0,0 +1,224 @@
package com.zioinfo.kintex.logistics;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.logistics.dto.DockBoardDto;
import com.zioinfo.kintex.logistics.dto.DockDto;
import com.zioinfo.kintex.logistics.dto.DockForecastDto;
import com.zioinfo.kintex.logistics.dto.DockGridReservationDto;
import com.zioinfo.kintex.logistics.dto.DockReservationRequest;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* M8 반입/반출 도크 서비스 도크 현황판·슬롯 예약(중복 409)·대기열 예측(단순 시간대 집계).
* 그리드는 08:00~19:00(12개 시간대). AI 예측은 예약 집계, 데이터 부재 표준 패턴 폴백.
*/
@Service
public class LogisticsService {
private static final int GRID_START_HOUR = 8;
private static final int HOURS = 12; // 08:00 ~ 19:00
private static final int GRID_END_HOUR = GRID_START_HOUR + HOURS; // 20 (exclusive)
private static final int DEFAULT_SPAN = 2;
/** 예약 데이터 부재 시 표준 대기열 패턴(오후 4시경 피크) — 폴백 명시. */
private static final List<Integer> FALLBACK_FORECAST =
List.of(20, 30, 40, 55, 70, 95, 100, 80, 50, 30, 22, 18);
private final LogisticsMapper mapper;
public LogisticsService(LogisticsMapper mapper) {
this.mapper = mapper;
}
/** 도크 현황판 — direction(in|out)/date. date 미지정 시 최근 예약 일자(없으면 오늘)로 해소. */
public DockBoardDto getBoard(String eventId, String direction, String date) {
String dir = normalizeDirection(direction);
String resolvedDate = resolveDate(eventId, dir, date);
List<Map<String, Object>> dockRows = mapper.findDocks(eventId);
List<DockDto> docks = new ArrayList<>(dockRows.size());
Map<String, Integer> indexByDockId = new LinkedHashMap<>();
int i = 0;
for (Map<String, Object> r : dockRows) {
String id = str(r.get("id"));
indexByDockId.put(id, i++);
docks.add(new DockDto(id, intVal(r.get("dockNo")), str(r.get("label")), boolVal(r.get("heavyPriority"))));
}
List<DockGridReservationDto> grid = new ArrayList<>();
for (Map<String, Object> r : mapper.findReservations(eventId, dir, resolvedDate)) {
Integer idx = indexByDockId.get(str(r.get("dockId")));
if (idx == null) {
continue; // 다른 도크(방어)
}
int startHour = intVal(r.get("startHour"));
int start = startHour - GRID_START_HOUR;
if (start < 0 || start >= HOURS) {
continue;
}
int span = Math.max(1, Math.min(intVal(r.get("span")), HOURS - start));
grid.add(new DockGridReservationDto(
str(r.get("id")), idx, start, span,
blockLabel(r), str(r.get("tone"))));
}
return new DockBoardDto(dir, resolvedDate, GRID_START_HOUR, HOURS, docks, grid);
}
/** 슬롯 예약 생성 — 중복 슬롯이면 409 CONFLICT. */
public DockGridReservationDto createReservation(String eventId, DockReservationRequest req) {
String dir = normalizeDirection(req.direction());
String date = requireDate(req.date());
List<Map<String, Object>> dockRows = mapper.findDocks(eventId);
if (dockRows.isEmpty()) {
throw new ApiException(ErrorCode.NOT_FOUND, "예약 가능한 도크가 없습니다.");
}
int dockIndex = req.dockIndex() == null ? -1 : req.dockIndex();
if (dockIndex < 0 || dockIndex >= dockRows.size()) {
throw new ApiException(ErrorCode.VALIDATION, "도크 선택이 올바르지 않습니다.");
}
String dockId = str(dockRows.get(dockIndex).get("id"));
int startHour = req.startHour() == null ? -1 : req.startHour();
int span = req.span() == null || req.span() < 1 ? DEFAULT_SPAN : req.span();
if (startHour < GRID_START_HOUR || startHour >= GRID_END_HOUR) {
throw new ApiException(ErrorCode.VALIDATION, "예약 시간대가 유효 범위를 벗어났습니다.");
}
int endHour = startHour + span;
if (endHour > GRID_END_HOUR) {
throw new ApiException(ErrorCode.VALIDATION, "예약 시간대가 운영 종료 시각을 초과합니다.");
}
if (mapper.countOverlaps(eventId, dockId, dir, date, startHour, endHour) > 0) {
throw new ApiException(ErrorCode.CONFLICT, "이미 예약된 슬롯과 겹칩니다. 다른 시간대를 선택하세요.");
}
String id = "dr-" + UUID.randomUUID();
Map<String, Object> p = new LinkedHashMap<>();
p.put("id", id);
p.put("eventId", eventId);
p.put("dockId", dockId);
p.put("direction", dir);
p.put("date", date);
p.put("startHour", startHour);
p.put("span", span);
p.put("companyName", req.companyName());
p.put("vehicleNo", req.vehicleNo());
p.put("vehicleWeight", req.weight());
p.put("item", req.item());
p.put("forklift", req.forklift() != null && req.forklift());
p.put("tone", "booked");
mapper.insertReservation(p);
Map<String, Object> saved = mapper.findReservationById(id);
int start = intVal(saved.get("startHour")) - GRID_START_HOUR;
return new DockGridReservationDto(id, dockIndex, start,
intVal(saved.get("span")), blockLabel(saved), str(saved.get("tone")));
}
/** 철거일 대기열 예측 — 일자별 도크 점유를 시간대 밀집도로 집계. 예약 없으면 폴백. */
public DockForecastDto getForecast(String eventId, String date) {
String resolvedDate = requireDate(date);
List<Map<String, Object>> occ = mapper.findOccupancyByDate(eventId, resolvedDate);
int dockCount = Math.max(1, mapper.findDocks(eventId).size());
if (occ.isEmpty()) {
return new DockForecastDto(FALLBACK_FORECAST, true, "16:00");
}
int[] count = new int[HOURS];
for (Map<String, Object> r : occ) {
int start = intVal(r.get("startHour")) - GRID_START_HOUR;
int span = Math.max(1, intVal(r.get("span")));
for (int h = start; h < start + span && h < HOURS; h++) {
if (h >= 0) {
count[h]++;
}
}
}
List<Integer> hourly = new ArrayList<>(HOURS);
int peakIdx = 0;
for (int h = 0; h < HOURS; h++) {
hourly.add((int) Math.round(count[h] * 100.0 / dockCount));
if (count[h] > count[peakIdx]) {
peakIdx = h;
}
}
String peak = String.format("%02d:00", GRID_START_HOUR + peakIdx);
return new DockForecastDto(hourly, false, peak);
}
// 헬퍼
private String resolveDate(String eventId, String dir, String date) {
String norm = nullIfBlank(date);
if (norm != null) {
return requireDate(norm);
}
String def = mapper.findDefaultDate(eventId, dir);
return def != null ? def : LocalDate.now().toString();
}
private static String requireDate(String date) {
String norm = nullIfBlank(date);
if (norm == null) {
return LocalDate.now().toString();
}
try {
return LocalDate.parse(norm).toString();
} catch (DateTimeParseException e) {
throw new ApiException(ErrorCode.VALIDATION, "date 형식은 YYYY-MM-DD 이어야 합니다.");
}
}
private static String normalizeDirection(String direction) {
String d = direction == null ? "in" : direction.trim().toLowerCase();
if (!"in".equals(d) && !"out".equals(d)) {
throw new ApiException(ErrorCode.VALIDATION, "direction 은 in 또는 out 이어야 합니다.");
}
return d;
}
/** 그리드 블록 라벨 — 우선 슬롯은 안내문, 그 외 업체·중량 조합. */
private static String blockLabel(Map<String, Object> r) {
if ("priority".equals(str(r.get("tone")))) {
return "중량물 우선 (5t 이상)";
}
String company = str(r.get("companyName"));
String weight = str(r.get("vehicleWeight"));
if (company == null && weight == null) {
return "예약됨";
}
if (company == null) {
return weight;
}
return weight == null ? company : company + " · " + weight;
}
private static String nullIfBlank(String s) {
return s == null || s.isBlank() ? null : s;
}
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;
}
}
private static boolean boolVal(Object o) {
return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.kintex.logistics.dto;
import java.util.List;
/**
* 도크 예약 현황판 (SCR-25). direction=in|out, date=조회 일자(서버 해소값).
* gridStartHour=그리드 시간대(08), hours=시간대 (12). reservations dockIndex docks 매핑.
*/
public record DockBoardDto(
String direction,
String date,
int gridStartHour,
int hours,
List<DockDto> docks,
List<DockGridReservationDto> reservations
) {
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.kintex.logistics.dto;
/** 하역장 도크(그리드 행). heavyPriority=중량물(5t↑) 우선 도크. */
public record DockDto(
String id,
int dockNo,
String label,
boolean heavyPriority
) {
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.kintex.logistics.dto;
import java.util.List;
/**
* 철거일 대기열 예측 (SCR-25 우측 AI 카드). hourly=시간대별 밀집도 %(08:00~19:00, 12개).
* fallback=true 예약 데이터 부재로 표준 패턴 폴백(프론트 "AI 예측" 배지 유지·폴백 고지).
*/
public record DockForecastDto(
List<Integer> hourly,
boolean fallback,
String peakLabel
) {
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.kintex.logistics.dto;
/**
* 도크 그리드 예약 블록 (SCR-25 좌측 스케줄). dockIndex docks 목록 0-based 위치.
* start=시작 시간대(08:000), span=점유 . tone: booked|priority.
*/
public record DockGridReservationDto(
String id,
int dockIndex,
int start,
int span,
String label,
String tone
) {
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.kintex.logistics.dto;
/**
* 도크 슬롯 예약 요청 (SCR-25 우측 ). dockIndex=docks 목록 0-based 위치, startHour=시작(8..19).
* span 미지정 서버 기본(2). weight/item/forklift/companyName/vehicleNo 예약 메타.
*/
public record DockReservationRequest(
String direction,
String date,
Integer dockIndex,
Integer startHour,
Integer span,
String vehicleNo,
String weight,
String item,
Boolean forklift,
String companyName
) {
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.kintex.marketing;
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.common.audit.Audited;
import com.zioinfo.kintex.marketing.dto.MarketingDtos.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* M12 EDM·캠페인 · 스폰서십 API (SCR-33·SCR-34). 모두 인증 필수({@link EventAccessGuard}).
* 캠페인 생성은 주최자/홀매니저 역할 게이트. 발송은 미구현(status 전이만).
*/
@RestController
@RequestMapping("/api/events/{eventId}")
public class MarketingController {
private final MarketingService service;
private final EventAccessGuard guard;
public MarketingController(MarketingService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** 캠페인 목록(선택 status 필터). */
@GetMapping("/campaigns")
public ApiResponse<List<CampaignDto>> campaigns(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(required = false) String status) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.campaigns(eventId, status));
}
/** 캠페인 생성(발송 미구현 — status 전이만). */
@Audited(action = "CAMPAIGN_CREATE", targetType = "edm_campaign")
@PostMapping("/campaigns")
public ApiResponse<CampaignDto> createCampaign(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestBody CampaignCreateRequest req) {
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
return ApiResponse.ok(service.createCampaign(eventId, req));
}
/** 스폰서십 패키지·판매 현황. */
@GetMapping("/sponsorship")
public ApiResponse<SponsorshipView> sponsorship(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.sponsorship(eventId));
}
}

View File

@ -0,0 +1,76 @@
package com.zioinfo.kintex.marketing;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* M12 EDM·캠페인 · 스폰서십 매퍼. Map 반환 @Select 별칭에 쌍따옴표(AS "x") 카멜케이스 보존.
*/
@Mapper
public interface MarketingMapper {
// 캠페인
@Select("""
<script>
SELECT id, name, status, audience, meta,
open_rate AS "openRate", click_rate AS "clickRate"
FROM edm_campaign
WHERE event_id = #{eventId}
<if test="status != null and status != ''">AND status = #{status}</if>
ORDER BY created_at DESC, id DESC
</script>
""")
List<Map<String, Object>> findCampaigns(@Param("eventId") String eventId, @Param("status") String status);
@Insert("""
INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate, scheduled_at)
VALUES (#{id}, #{eventId}, #{name}, #{status}, #{audience}, #{meta}, '-', '-',
CASE WHEN #{scheduledAt} IS NULL OR #{scheduledAt} = '' THEN NULL ELSE CAST(#{scheduledAt} AS timestamptz) END)
""")
int insertCampaign(@Param("id") String id,
@Param("eventId") String eventId,
@Param("name") String name,
@Param("status") String status,
@Param("audience") int audience,
@Param("meta") String meta,
@Param("scheduledAt") String scheduledAt);
@Select("SELECT count(*) FROM event WHERE id = #{eventId}")
long eventExists(@Param("eventId") String eventId);
// 스폰서십
@Select("""
SELECT id, code, name, name_ko AS "nameKo", price, accent,
benefits::text AS "benefits",
total_qty AS "totalQty", sold_qty AS "soldQty", dday
FROM sponsorship_package
WHERE event_id = #{eventId}
ORDER BY sort_order, id
""")
List<Map<String, Object>> findPackages(@Param("eventId") String eventId);
@Select("""
SELECT id, name, tier_label AS "tierLabel", contract_status AS "contractStatus",
fulfillment::text AS "fulfillment"
FROM sponsorship_sponsor
WHERE event_id = #{eventId}
ORDER BY created_at, id
""")
List<Map<String, Object>> findSponsors(@Param("eventId") String eventId);
/** 스폰서십 요약 원자 집계(스폰서 수·판매액·잔여 패키지·이행 진행률). */
@Select("""
SELECT
(SELECT count(*) FROM sponsorship_sponsor s WHERE s.event_id = #{eventId}) AS "sponsorCount",
(SELECT COALESCE(sum(p.price), 0) FROM sponsorship_package p
WHERE p.event_id = #{eventId}) AS "priceTotalRef",
(SELECT COALESCE(sum(GREATEST(p.total_qty - p.sold_qty, 0)), 0) FROM sponsorship_package p
WHERE p.event_id = #{eventId}) AS "remainingPackages"
""")
Map<String, Object> findSponsorshipCounts(@Param("eventId") String eventId);
}

View File

@ -0,0 +1,178 @@
package com.zioinfo.kintex.marketing;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.marketing.dto.MarketingDtos.*;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* M12 EDM·캠페인 · 스폰서십 서비스.
* <p> 캠페인 발송은 미구현 생성 status 전이만 결정한다(즉시=draft, 예약=scheduled). 발송 게이트웨이 연동은 후속 .
*/
@Service
public class MarketingService {
private static final ObjectMapper JSON = new ObjectMapper();
private final MarketingMapper mapper;
public MarketingService(MarketingMapper mapper) {
this.mapper = mapper;
}
// 캠페인
public List<CampaignDto> campaigns(String eventId, String status) {
List<Map<String, Object>> rows = mapper.findCampaigns(eventId, blankToNull(status));
List<CampaignDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
if (rows != null) {
for (Map<String, Object> r : rows) {
out.add(new CampaignDto(
str(r.get("id")), str(r.get("name")), str(r.get("status")), intVal(r.get("audience")),
str(r.get("meta")), str(r.get("openRate")), str(r.get("clickRate"))));
}
}
return out;
}
public CampaignDto createCampaign(String eventId, CampaignCreateRequest req) {
String name = trimOrNull(req.name());
if (name == null) {
throw new ApiException(ErrorCode.VALIDATION, "캠페인 이름을 입력해 주세요.");
}
if (mapper.eventExists(eventId) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
}
boolean later = "later".equalsIgnoreCase(str(req.scheduleType()))
&& req.scheduledAt() != null && !req.scheduledAt().isBlank();
String status = later ? "scheduled" : "draft";
int audience = req.audience() == null ? 0 : Math.max(req.audience(), 0);
String meta = later ? "발송 예정: " + req.scheduledAt() : "최종 수정: 방금 전";
String id = "cp-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
// 문자열로 전달( 바인딩 회피) 매퍼가 '' NULL scheduled_at 으로 처리.
mapper.insertCampaign(id, eventId, name, status, audience, meta,
later ? req.scheduledAt() : "");
return new CampaignDto(id, name, status, audience, meta, "-", "-");
}
// 스폰서십
public SponsorshipView sponsorship(String eventId) {
List<Map<String, Object>> pkgRows = mapper.findPackages(eventId);
List<Map<String, Object>> sponsorRows = mapper.findSponsors(eventId);
Map<String, Object> counts = mapper.findSponsorshipCounts(eventId);
if (counts == null) counts = Map.of();
List<TierDto> tiers = new ArrayList<>();
long soldValue = 0;
if (pkgRows != null) {
for (Map<String, Object> p : pkgRows) {
long total = lng(p.get("totalQty"));
long sold = lng(p.get("soldQty"));
long price = lng(p.get("price"));
int remaining = (int) Math.max(total - sold, 0);
soldValue += price * sold;
tiers.add(new TierDto(
str(p.get("id")), str(p.get("name")), str(p.get("nameKo")),
formatWon(price), str(p.get("accent")), parseStrings(p.get("benefits")),
remaining, remaining <= 0 ? "soldout" : "available",
p.get("dday") == null ? null : intVal(p.get("dday"))));
}
}
List<SponsorDto> sponsors = new ArrayList<>();
long fulfillTotal = 0, fulfillDone = 0;
if (sponsorRows != null) {
for (Map<String, Object> s : sponsorRows) {
List<Fulfillment> f = parseFulfillment(s.get("fulfillment"));
for (Fulfillment item : f) {
fulfillTotal++;
if (item.done()) fulfillDone++;
}
String name = str(s.get("name"));
sponsors.add(new SponsorDto(
str(s.get("id")), name,
(name == null || name.isEmpty()) ? "?" : name.substring(0, 1),
str(s.get("tierLabel")), str(s.get("contractStatus")), f));
}
}
List<MktKpi> kpis = new ArrayList<>();
kpis.add(new MktKpi("스폰서", String.valueOf(lng(counts.get("sponsorCount"))), "", "primary"));
kpis.add(new MktKpi("판매액", formatMillion(soldValue), null, "primary"));
kpis.add(new MktKpi("잔여 패키지", String.valueOf(lng(counts.get("remainingPackages"))), "", "ai"));
kpis.add(new MktKpi("이행물 진행", fulfillTotal > 0 ? String.valueOf(Math.round(fulfillDone * 100.0 / fulfillTotal)) : "0",
"%", "success"));
return new SponsorshipView(kpis, tiers, sponsors);
}
// helpers
private List<String> parseStrings(Object json) {
if (json == null) return List.of();
try {
return JSON.readValue(String.valueOf(json), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {
});
} catch (Exception e) {
return List.of();
}
}
private List<Fulfillment> parseFulfillment(Object json) {
if (json == null) return List.of();
try {
List<Map<String, Object>> raw = JSON.readValue(String.valueOf(json),
new com.fasterxml.jackson.core.type.TypeReference<List<Map<String, Object>>>() {
});
List<Fulfillment> out = new ArrayList<>(raw.size());
for (Map<String, Object> m : raw) {
out.add(new Fulfillment(str(m.get("id")), str(m.get("label")),
m.get("done") instanceof Boolean b ? b : Boolean.parseBoolean(str(m.get("done")))));
}
return out;
} catch (Exception e) {
return List.of();
}
}
private static String formatWon(long won) {
return "" + String.format("%,d", won);
}
private static String formatMillion(long won) {
double m = won / 1_000_000.0;
return "" + String.format("%.1f", m) + "M";
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s;
}
private static String trimOrNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static long lng(Object o) {
if (o == null) return 0;
if (o instanceof Number n) return n.longValue();
try {
return Long.parseLong(String.valueOf(o));
} catch (NumberFormatException e) {
return 0;
}
}
private static int intVal(Object o) {
return (int) lng(o);
}
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.kintex.marketing.dto;
import java.util.List;
/**
* M12 EDM·캠페인 · 스폰서십 응답/요청 DTO 모음.
* <p>스폰서·기업명은 민감정보가 아니다(공개 마케팅 대상). 관람객 PII 모듈이 다루지 않는다.
*/
public final class MarketingDtos {
private MarketingDtos() {
}
// SCR-33 EDM·캠페인
public record CampaignDto(String id, String name, String status, int audience,
String meta, String openRate, String clickRate) {
}
/** 캠페인 생성 요청 — 발송은 미구현(status 전이만). */
public record CampaignCreateRequest(String name, Integer audience, String meta,
String scheduleType, String scheduledAt) {
}
// SCR-34 스폰서십
public record SponsorshipView(List<MktKpi> kpis, List<TierDto> tiers, List<SponsorDto> sponsors) {
}
public record MktKpi(String label, String value, String unit, String tone) {
}
public record TierDto(String id, String name, String nameKo, String price, String accent,
List<String> benefits, int remaining, String status, Integer dday) {
}
public record SponsorDto(String id, String name, String initial, String tier, String status,
List<Fulfillment> fulfillment) {
}
public record Fulfillment(String id, String label, boolean done) {
}
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.kintex.publicsite;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.publicsite.dto.InquiryReceiptDto;
import com.zioinfo.kintex.publicsite.dto.InquiryRequest;
import com.zioinfo.kintex.publicsite.dto.PublicEventDto;
import com.zioinfo.kintex.publicsite.dto.PublicFloorplanDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 공개 홍보 사이트(M12) API 경로 비인증 공개(SecurityConfig 에서 {@code /api/public/**} permitAll).
* <p>공개 카탈로그(행사·플로어플랜) read-only, 문의는 접수만. 내부 식별자·PII·원가 미노출.
* <p>관람객 사전등록(POST {@code /api/public/events/{id}/register}) visitor 트랙 소관 여기서 정의하지 않는다.
*/
@RestController
@RequestMapping("/api/public")
public class PublicSiteController {
private static final int MAX_LIMIT = 2000;
private final PublicSiteService service;
public PublicSiteController(PublicSiteService service) {
this.service = service;
}
/** GET /api/public/events?year=&q= — 공개 카탈로그(시작일 내림차순). */
@GetMapping("/events")
public ApiResponse<List<PublicEventDto>> events(@RequestParam(required = false) String year,
@RequestParam(required = false) String q,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "2000") int size) {
int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
int offset = Math.max(page, 0) * limit;
return ApiResponse.ok(service.listEvents(year, q, limit, offset));
}
/** GET /api/public/events/{eventId} — 공개 행사 상세(공개 필드만). */
@GetMapping("/events/{eventId}")
public ApiResponse<PublicEventDto> event(@PathVariable String eventId) {
return ApiResponse.ok(service.getEvent(eventId));
}
/** GET /api/public/events/{eventId}/floorplan — 공개 플로어플랜 요약(부스번호·업체명·상태·중심점). */
@GetMapping("/events/{eventId}/floorplan")
public ApiResponse<PublicFloorplanDto> floorplan(@PathVariable String eventId) {
return ApiResponse.ok(service.getFloorplan(eventId));
}
/** POST /api/public/inquiries — 참가/부스 신청 문의 접수(접수번호 반환). */
@PostMapping("/inquiries")
public ApiResponse<InquiryReceiptDto> inquiry(@RequestBody InquiryRequest req) {
return ApiResponse.ok(service.submitInquiry(req));
}
}

View File

@ -0,0 +1,150 @@
package com.zioinfo.kintex.publicsite;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* 공개 홍보 사이트(M12) 매퍼 기존 event·hall·layout·booth read-only 조회, 문의만 write.
* <p> Map 반환 @Select camelCase 별칭에 반드시 쌍따옴표({@code AS "x"}) 사용한다(PG lower-fold 방지).
* <p>공개 응답 정책: 내부 booth id·폴리곤·비용·PII 컬럼은 select 하지 않는다.
*/
@Mapper
public interface PublicSiteMapper {
// 공개 카탈로그(event read-only)
/** 공개 행사 카탈로그 — year(시작연도)·q(이름 부분검색) 선택 필터. 시작일 내림차순. */
@Select("""
<script>
SELECT e.id,
e.name,
to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
e.status,
h.label AS "hallLabel",
(SELECT count(*)::int
FROM booth b
JOIN layout l ON l.id = b.layout_id
WHERE l.event_id = e.id) AS "boothCount"
FROM event e
LEFT JOIN LATERAL (
SELECT ha.hall_id
FROM hall_assignment ha
WHERE ha.event_id = e.id
ORDER BY ha.is_primary DESC
LIMIT 1
) pa ON true
LEFT JOIN hall h ON h.id = pa.hall_id
<where>
<if test="year != null and year != ''">
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
</if>
<if test="q != null and q != ''">
AND e.name ILIKE ('%' || #{q} || '%')
</if>
</where>
ORDER BY e.start_date DESC NULLS LAST, e.id DESC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> findEvents(@Param("year") String year,
@Param("q") String q,
@Param("limit") int limit,
@Param("offset") int offset);
/** 공개 행사 상세(단건) — 공개 필드만. */
@Select("""
SELECT e.id,
e.name,
to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
e.status,
h.label AS "hallLabel",
(SELECT count(*)::int
FROM booth b
JOIN layout l ON l.id = b.layout_id
WHERE l.event_id = e.id) AS "boothCount"
FROM event e
LEFT JOIN LATERAL (
SELECT ha.hall_id FROM hall_assignment ha
WHERE ha.event_id = e.id ORDER BY ha.is_primary DESC LIMIT 1
) pa ON true
LEFT JOIN hall h ON h.id = pa.hall_id
WHERE e.id = #{eventId}
""")
Map<String, Object> findEvent(@Param("eventId") String eventId);
// 공개 플로어플랜(hall·booth read-only)
/** 행사 배정 홀 목록 — 공개 라벨·전시장만. */
@Select("""
SELECT h.id AS "hallId",
h.label AS "label",
h.exhibition_center AS "center",
ha.is_primary AS "primary"
FROM hall_assignment ha
JOIN hall h ON h.id = ha.hall_id
WHERE ha.event_id = #{eventId}
ORDER BY ha.is_primary DESC, h.id
""")
List<Map<String, Object>> findEventHalls(@Param("eventId") String eventId);
/**
* 공개 부스 요약 홀별 최신 버전 배치안의 부스만. 폴리곤은 중심점(cx/cy)으로 단순화.
* status 업체 배정 여부로 파생(reserved/available). 내부 booth id 미노출.
*/
@Select("""
SELECT b.booth_no AS "boothNo",
b.assigned_company_name AS "companyName",
b.booth_type AS "boothType",
CASE WHEN b.assigned_company_name IS NOT NULL
AND b.assigned_company_name <> ''
THEN 'reserved' ELSE 'available' END AS "status",
round(ST_X(ST_Centroid(b.geom))::numeric, 2) AS "cx",
round(ST_Y(ST_Centroid(b.geom))::numeric, 2) AS "cy",
h.label AS "hallLabel"
FROM booth b
JOIN layout l ON l.id = b.layout_id
JOIN hall h ON h.id = l.hall_id
WHERE l.event_id = #{eventId}
AND l.version = (SELECT max(l2.version) FROM layout l2
WHERE l2.event_id = l.event_id AND l2.hall_id = l.hall_id)
ORDER BY b.booth_no NULLS LAST
LIMIT #{limit}
""")
List<Map<String, Object>> findPublicBooths(@Param("eventId") String eventId,
@Param("limit") int limit);
// 문의 접수(write) + rate
/** 동일 이메일의 최근 window(초) 내 접수 건수 — 단순 rate 제어(1분 1건). */
@Select("""
SELECT count(*)::int FROM exhibit_inquiry
WHERE email = #{email}
AND created_at > now() - make_interval(secs => #{windowSeconds})
""")
int countRecentByEmail(@Param("email") String email, @Param("windowSeconds") int windowSeconds);
/** 문의 저장(공개 접수). */
@org.apache.ibatis.annotations.Insert("""
INSERT INTO exhibit_inquiry
(id, receipt_no, inquiry_type, company, contact_name, email, phone,
scale, hall_pref, message, agree_privacy, agree_marketing, status)
VALUES
(#{id}, #{receiptNo}, #{inquiryType}, #{company}, #{contactName}, #{email}, #{phone},
#{scale}, #{hallPref}, #{message}, #{agreePrivacy}, #{agreeMarketing}, 'received')
""")
void insertInquiry(@Param("id") String id,
@Param("receiptNo") String receiptNo,
@Param("inquiryType") String inquiryType,
@Param("company") String company,
@Param("contactName") String contactName,
@Param("email") String email,
@Param("phone") String phone,
@Param("scale") String scale,
@Param("hallPref") String hallPref,
@Param("message") String message,
@Param("agreePrivacy") boolean agreePrivacy,
@Param("agreeMarketing") boolean agreeMarketing);
}

View File

@ -0,0 +1,133 @@
package com.zioinfo.kintex.publicsite;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.publicsite.dto.InquiryReceiptDto;
import com.zioinfo.kintex.publicsite.dto.InquiryRequest;
import com.zioinfo.kintex.publicsite.dto.PublicEventDto;
import com.zioinfo.kintex.publicsite.dto.PublicFloorplanDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Pattern;
/**
* 공개 홍보 사이트(M12) 서비스 카탈로그 read-only 조회 + 문의 접수(rate 제어).
* 경로 비인증 공개. 공개 응답에는 내부 식별자·PII·원가를 담지 않는다.
*/
@Service
public class PublicSiteService {
private static final int BOOTH_LIMIT = 3000;
private static final int RATE_WINDOW_SECONDS = 60; // 동일 이메일 1분 1건
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
private static final DateTimeFormatter RECEIPT_DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
private final PublicSiteMapper mapper;
public PublicSiteService(PublicSiteMapper mapper) {
this.mapper = mapper;
}
// 카탈로그
public List<PublicEventDto> listEvents(String year, String q, int limit, int offset) {
return mapper.findEvents(nullIfBlank(year), nullIfBlank(q), limit, offset)
.stream().map(this::toEvent).toList();
}
public PublicEventDto getEvent(String eventId) {
Map<String, Object> row = mapper.findEvent(eventId);
if (row == null) throw new ApiException(ErrorCode.NOT_FOUND);
return toEvent(row);
}
public PublicFloorplanDto getFloorplan(String eventId) {
Map<String, Object> ev = mapper.findEvent(eventId);
if (ev == null) throw new ApiException(ErrorCode.NOT_FOUND);
List<PublicFloorplanDto.Hall> halls = mapper.findEventHalls(eventId).stream()
.map(m -> new PublicFloorplanDto.Hall(
str(m.get("hallId")), str(m.get("label")),
toInt(m.get("center")), Boolean.TRUE.equals(m.get("primary"))))
.toList();
List<PublicFloorplanDto.Booth> booths = mapper.findPublicBooths(eventId, BOOTH_LIMIT).stream()
.map(m -> new PublicFloorplanDto.Booth(
str(m.get("boothNo")), str(m.get("companyName")), str(m.get("boothType")),
str(m.get("status")), toDouble(m.get("cx")), toDouble(m.get("cy")),
str(m.get("hallLabel"))))
.toList();
return new PublicFloorplanDto(str(ev.get("id")), str(ev.get("name")), halls, booths);
}
// 문의 접수
@Transactional
public InquiryReceiptDto submitInquiry(InquiryRequest req) {
String name = nullIfBlank(req.contactName());
String email = nullIfBlank(req.email());
String phone = nullIfBlank(req.phone());
if (!Boolean.TRUE.equals(req.agreePrivacy())) {
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용 동의(필수)가 필요합니다.");
}
if (name == null) {
throw new ApiException(ErrorCode.VALIDATION, "담당자 이름을 입력해 주세요.");
}
if (email == null && phone == null) {
throw new ApiException(ErrorCode.VALIDATION, "연락 가능한 이메일 또는 전화번호를 입력해 주세요.");
}
if (email != null && !EMAIL.matcher(email).matches()) {
throw new ApiException(ErrorCode.VALIDATION, "이메일 형식이 올바르지 않습니다.");
}
if (nullIfBlank(req.message()) == null) {
throw new ApiException(ErrorCode.VALIDATION, "문의 내용을 입력해 주세요.");
}
// 단순 rate: 동일 이메일 1분 1건(이메일 있을 때만 적용)
if (email != null && mapper.countRecentByEmail(email, RATE_WINDOW_SECONDS) > 0) {
throw new ApiException(ErrorCode.CONFLICT,
"같은 이메일로 잠시 전 문의가 접수되었습니다. 1분 후 다시 시도해 주세요.");
}
String receiptNo = generateReceiptNo();
mapper.insertInquiry(
UUID.randomUUID().toString(), receiptNo,
nullIfBlank(req.inquiryType()), nullIfBlank(req.company()), name, email, phone,
nullIfBlank(req.scale()), nullIfBlank(req.hallPref()), nullIfBlank(req.message()),
true, Boolean.TRUE.equals(req.agreeMarketing()));
return new InquiryReceiptDto(receiptNo, "received");
}
// helpers
private PublicEventDto toEvent(Map<String, Object> m) {
return new PublicEventDto(
str(m.get("id")), str(m.get("name")), str(m.get("startDate")), str(m.get("endDate")),
str(m.get("status")), str(m.get("hallLabel")), toInt(m.get("boothCount")));
}
private String generateReceiptNo() {
String day = LocalDate.now().format(RECEIPT_DAY);
int rand = ThreadLocalRandom.current().nextInt(0, 10000);
return "KTX-INQ-" + day + "-" + String.format("%04d", rand);
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static Integer toInt(Object o) {
return (o instanceof Number n) ? n.intValue() : null;
}
private static Double toDouble(Object o) {
return (o instanceof Number n) ? n.doubleValue() : null;
}
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.kintex.publicsite.dto;
/**
* 문의 접수 결과 (M12 / SCR-P6). 공개 응답 접수번호·상태만 반환(PII 미노출).
*/
public record InquiryReceiptDto(
String receiptNo,
String status
) {
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.kintex.publicsite.dto;
/**
* 참가/부스 신청 문의 접수 입력 (M12 / SCR-P6). 비인증 공개 POST 본문.
* <p>서버는 agreePrivacy(필수)·연락 수단(email 또는 phone)·contactName 검증한다.
*/
public record InquiryRequest(
String inquiryType, // shell | raw | premium | general (선택)
String company,
String contactName,
String email,
String phone,
String scale,
String hallPref,
String message,
Boolean agreePrivacy,
Boolean agreeMarketing
) {
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.kintex.publicsite.dto;
/**
* 공개 카탈로그 행사 항목 (M12 / SCR-P1·P2). 비인증 공개.
* <p>공개 필드만 노출한다 내부 식별자(멤버·비용·비공개 상태) 제외.
* status {@code event.status} DB 원문(active/ended) 그대로 반환하고 프론트가 날짜 기준 재계산한다.
*/
public record PublicEventDto(
String id,
String name,
String startDate,
String endDate,
String status,
String hallLabel,
Integer boothCount
) {
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.kintex.publicsite.dto;
import java.util.List;
/**
* 공개 플로어플랜 요약 (M12·M2 / SCR-P3). 비인증 공개.
* <p>좌표 폴리곤은 단순화(부스 중심점 cx/cy) 반환한다 폴리곤·트렌치·내부 식별자는 제외.
*/
public record PublicFloorplanDto(
String eventId,
String eventName,
List<Hall> halls,
List<Booth> booths
) {
/** 공개 홀 요약(전시장·라벨만). */
public record Hall(String hallId, String label, Integer center, boolean primary) {
}
/** 공개 부스 요약 — 부스번호·업체명(표시용)·유형·상태·중심점만. 내부 booth id·폴리곤 미노출. */
public record Booth(
String boothNo,
String companyName,
String boothType,
String status, // available | reserved
Double cx,
Double cy,
String hallLabel
) {
}
}

View File

@ -0,0 +1,66 @@
package com.zioinfo.kintex.visitor;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.audit.Audited;
import com.zioinfo.kintex.visitor.dto.VisitorDtos.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/**
* M10 관람객·리드 API (SCR-30·SCR-32) + 공개 사전등록 접수.
* <ul>
* <li>공개(비인증): {@code POST /api/public/events/{eventId}/register} /api/public/** permitAll.</li>
* <li>인증: 요약·목록·리드 {@link EventAccessGuard} 행사 접근 가드. 응답은 마스킹 필드만(PII 불변).</li>
* </ul>
*/
@RestController
public class VisitorController {
private final VisitorService service;
private final EventAccessGuard guard;
public VisitorController(VisitorService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** 공개 사전등록 접수(인증 불필요) — 중복 이메일은 기존 등록 재반환. */
@Audited(action = "VISITOR_REGISTER", targetType = "visitor_registration")
@PostMapping("/api/public/events/{eventId}/register")
public ApiResponse<RegisterResult> register(@PathVariable String eventId,
@RequestBody RegisterRequest req) {
return ApiResponse.ok(service.register(eventId, req));
}
/** 등록 대시보드 요약(집계·추이·유형 분포·폼). */
@GetMapping("/api/events/{eventId}/visitors/summary")
public ApiResponse<VisitorSummary> summary(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.summary(eventId));
}
/** 등록자 목록(마스킹). */
@GetMapping("/api/events/{eventId}/visitors")
public ApiResponse<PageResponse<VisitorListItem>> visitors(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.visitors(eventId, page, size));
}
/** 리드 목록(마스킹 + AI 저장 스코어). booth 선택 필터. */
@GetMapping("/api/events/{eventId}/leads")
public ApiResponse<PageResponse<LeadItem>> leads(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(required = false) String boothId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.leads(eventId, boothId, page, size));
}
}

View File

@ -0,0 +1,164 @@
package com.zioinfo.kintex.visitor;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* M10 관람객·리드 매퍼 {@code visitor_registration}·{@code lead} read/insert.
* <p> PII 불변: 리스트 조회 SQL 원문 name/phone/email SELECT 하지 않고 <b>마스킹 표현식</b> 반환한다.
* Map 반환 @Select 별칭에 쌍따옴표(AS "x") 사용해 카멜케이스 키를 보존한다.
*/
@Mapper
public interface VisitorMapper {
// 요약 집계
/** 등록/체크인/유형 원자 집계(단일 행). */
@Select("""
SELECT
count(*) AS "total",
count(*) FILTER (WHERE checkin_state = 'done') AS "checkedIn",
count(*) FILTER (WHERE checkin_state = 'waiting') AS "waiting",
count(*) FILTER (WHERE checkin_state = 'cancelled') AS "cancelled",
count(*) FILTER (WHERE visitor_type = 'buyer') AS "buyers",
count(*) FILTER (WHERE visitor_type = 'vip') AS "vips",
count(*) FILTER (WHERE badge_issued) AS "badgeIssued"
FROM visitor_registration
WHERE event_id = #{eventId}
""")
Map<String, Object> findSummaryCounts(@Param("eventId") String eventId);
/** 리드 건수(스코어 80+ 핫리드 포함). */
@Select("""
SELECT count(*) AS "total",
count(*) FILTER (WHERE score >= 80) AS "hot"
FROM lead WHERE event_id = #{eventId}
""")
Map<String, Object> findLeadCounts(@Param("eventId") String eventId);
/** 일자별 등록/체크인 건수(누적은 서비스에서 산출). */
@Select("""
SELECT to_char(registered_at, 'MM.DD') AS "day",
count(*) AS "regCount",
count(*) FILTER (WHERE checkin_state = 'done') AS "checkinCount"
FROM visitor_registration
WHERE event_id = #{eventId}
GROUP BY registered_at::date, to_char(registered_at, 'MM.DD')
ORDER BY registered_at::date
""")
List<Map<String, Object>> findDailyTrend(@Param("eventId") String eventId);
/** 유형별 분포 집계. */
@Select("""
SELECT visitor_type AS "type", count(*) AS "cnt"
FROM visitor_registration
WHERE event_id = #{eventId}
GROUP BY visitor_type
""")
List<Map<String, Object>> findTypeCounts(@Param("eventId") String eventId);
// 목록(마스킹)
/** 등록자 목록 — 마스킹 필드만 SELECT(원문 미반환). */
@Select("""
SELECT id,
CASE WHEN char_length(name) <= 1 THEN name
WHEN char_length(name) = 2 THEN left(name,1) || '*'
ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1)
END AS "nameMasked",
visitor_type AS "type",
company,
to_char(registered_at, 'YYYY.MM.DD') AS "registeredAt",
checkin_state AS "checkin",
badge_issued AS "badgeIssued"
FROM visitor_registration
WHERE event_id = #{eventId}
ORDER BY registered_at DESC, id DESC
LIMIT #{limit} OFFSET #{offset}
""")
List<Map<String, Object>> findVisitorPage(@Param("eventId") String eventId,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("SELECT count(*) FROM visitor_registration WHERE event_id = #{eventId}")
long countVisitors(@Param("eventId") String eventId);
/** 리드 목록 — 마스킹 + AI 저장값. jsonb 는 ::text 로 반환해 서비스에서 파싱. booth 선택 필터. */
@Select("""
<script>
SELECT id,
CASE WHEN char_length(name) <= 1 THEN name
WHEN char_length(name) = 2 THEN left(name,1) || '*'
ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1)
END AS "nameMasked",
role, company, product, interest, score,
to_char(collected_at, 'HH24:MI:SS') AS "collectedAt",
CASE WHEN phone IS NULL THEN NULL
WHEN char_length(regexp_replace(phone,'\\D','','g')) >= 7
THEN left(regexp_replace(phone,'\\D','','g'),3) || '-****-' || right(regexp_replace(phone,'\\D','','g'),4)
ELSE '***' END AS "phoneMasked",
CASE WHEN email IS NULL OR position('@' in email) = 0 THEN NULL
ELSE left(split_part(email,'@',1),3) || '***@' || split_part(email,'@',2)
END AS "emailMasked",
ai_reasons::text AS "aiReasons",
activity::text AS "activity",
followup_draft AS "followupDraft",
ai_generated AS "aiGenerated"
FROM lead
WHERE event_id = #{eventId}
<if test="boothId != null and boothId != ''">AND booth_id = #{boothId}</if>
ORDER BY score DESC, collected_at DESC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> findLeadPage(@Param("eventId") String eventId,
@Param("boothId") String boothId,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT count(*) FROM lead
WHERE event_id = #{eventId}
<if test="boothId != null and boothId != ''">AND booth_id = #{boothId}</if>
</script>
""")
long countLeads(@Param("eventId") String eventId, @Param("boothId") String boothId);
// 공개 사전등록
/** 행사+이메일 기존 등록 조회(중복 시 재반환). 원문 미반환(id·badge_code 만). */
@Select("""
SELECT id AS "registrationId", badge_code AS "badgeCode"
FROM visitor_registration
WHERE event_id = #{eventId} AND lower(email) = lower(#{email})
LIMIT 1
""")
Map<String, Object> findByEventEmail(@Param("eventId") String eventId, @Param("email") String email);
/** 행사 존재 확인(공개 등록 대상 유효성). */
@Select("SELECT count(*) FROM event WHERE id = #{eventId}")
long eventExists(@Param("eventId") String eventId);
/** 사전등록 삽입 — 원문 저장(응답에는 노출 금지). id·badgeCode 는 서비스가 생성. */
@Insert("""
INSERT INTO visitor_registration
(id, event_id, name, phone, email, company, visitor_type, agree_privacy, agree_marketing, badge_code, badge_issued, checkin_state)
VALUES
(#{id}, #{eventId}, #{name}, #{phone}, #{email}, NULL, #{visitorType}, #{agreePrivacy}, #{agreeMarketing}, #{badgeCode}, false, 'waiting')
""")
int insertRegistration(@Param("id") String id,
@Param("eventId") String eventId,
@Param("name") String name,
@Param("phone") String phone,
@Param("email") String email,
@Param("visitorType") String visitorType,
@Param("agreePrivacy") boolean agreePrivacy,
@Param("agreeMarketing") boolean agreeMarketing,
@Param("badgeCode") String badgeCode);
}

View File

@ -0,0 +1,260 @@
package com.zioinfo.kintex.visitor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.visitor.dto.VisitorDtos.*;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* M10 관람객·리드 서비스 집계·목록(마스킹)·공개 사전등록.
* <p> PII 불변: 매퍼는 이미 마스킹된 필드만 반환한다. 서비스는 원문 PII 응답 DTO 옮기지 않는다.
* 원문은 오직 등록 INSERT 파라미터로만 사용하고 어떤 반환/로그에도 담지 않는다.
*/
@Service
public class VisitorService {
private static final ObjectMapper JSON = new ObjectMapper();
/** 유형 표시 라벨·색상(프론트 도넛 팔레트와 정렬). */
private static final Map<String, String[]> TYPE_META = Map.of(
"visitor", new String[]{"관람객", "#0066B3"},
"buyer", new String[]{"바이어", "#6D4AFF"},
"vip", new String[]{"VIP", "#0E8A5F"});
private final VisitorMapper mapper;
public VisitorService(VisitorMapper mapper) {
this.mapper = mapper;
}
// SCR-30 요약
public VisitorSummary summary(String eventId) {
Map<String, Object> c = orEmpty(mapper.findSummaryCounts(eventId));
Map<String, Object> lc = orEmpty(mapper.findLeadCounts(eventId));
long total = lng(c.get("total"));
long checkedIn = lng(c.get("checkedIn"));
long waiting = lng(c.get("waiting"));
long cancelled = lng(c.get("cancelled"));
long buyers = lng(c.get("buyers"));
long leads = lng(lc.get("total"));
List<Kpi> kpis = new ArrayList<>();
kpis.add(new Kpi("사전등록", fmt(total), null, null, null, null));
kpis.add(new Kpi("체크인", fmt(checkedIn), null, null, null,
total > 0 ? "진행률 " + pct(checkedIn, total) : null));
long noShowBase = checkedIn + waiting + cancelled;
kpis.add(new Kpi("노쇼/취소", fmt(waiting + cancelled), null, "down", null,
noShowBase > 0 ? pct(waiting + cancelled, noShowBase) : null));
kpis.add(new Kpi("바이어 비중", total > 0 ? pct(buyers, total) : "0%", null, null, null, "목표 30%"));
kpis.add(new Kpi("리드 생성", fmt(leads), null, null, true, "AI 스코어링 대상"));
return new VisitorSummary(kpis, buildTrend(eventId), buildTypes(eventId, total), buildForms(eventId));
}
private List<TrendPoint> buildTrend(String eventId) {
List<Map<String, Object>> rows = mapper.findDailyTrend(eventId);
List<TrendPoint> out = new ArrayList<>();
long cumReg = 0, cumChk = 0;
if (rows != null) {
for (Map<String, Object> r : rows) {
cumReg += lng(r.get("regCount"));
cumChk += lng(r.get("checkinCount"));
out.add(new TrendPoint(str(r.get("day")), cumReg, cumChk));
}
}
return out;
}
private List<TypeSlice> buildTypes(String eventId, long total) {
List<Map<String, Object>> rows = mapper.findTypeCounts(eventId);
List<TypeSlice> out = new ArrayList<>();
if (rows != null) {
for (Map<String, Object> r : rows) {
String type = str(r.get("type"));
long cnt = lng(r.get("cnt"));
String[] meta = TYPE_META.getOrDefault(type, new String[]{type, "#98A2B3"});
int value = total > 0 ? (int) Math.round(cnt * 100.0 / total) : 0;
out.add(new TypeSlice(meta[0], value, meta[1]));
}
}
return out;
}
/** 등록 폼 — 유형 카운트에서 파생(폼 마스터 부재 시). */
private List<FormItem> buildForms(String eventId) {
List<Map<String, Object>> rows = mapper.findTypeCounts(eventId);
Map<String, Long> byType = new java.util.HashMap<>();
if (rows != null) {
for (Map<String, Object> r : rows) {
byType.put(str(r.get("type")), lng(r.get("cnt")));
}
}
List<FormItem> out = new ArrayList<>();
out.add(new FormItem("general", "일반 참관객", true, byType.getOrDefault("visitor", 0L)));
out.add(new FormItem("buyer", "비즈니스 바이어", true, byType.getOrDefault("buyer", 0L)));
out.add(new FormItem("vip", "VIP 초청", byType.getOrDefault("vip", 0L) > 0, byType.getOrDefault("vip", 0L)));
return out;
}
// SCR-30 목록(마스킹)
public PageResponse<VisitorListItem> visitors(String eventId, int page, int size) {
int limit = clamp(size, 1, 200);
int offset = Math.max(page, 0) * limit;
List<Map<String, Object>> rows = mapper.findVisitorPage(eventId, limit, offset);
List<VisitorListItem> items = new ArrayList<>(rows == null ? 0 : rows.size());
if (rows != null) {
for (Map<String, Object> r : rows) {
items.add(new VisitorListItem(
str(r.get("id")), str(r.get("nameMasked")), str(r.get("type")),
str(r.get("company")), str(r.get("registeredAt")), str(r.get("checkin")),
bool(r.get("badgeIssued"))));
}
}
return PageResponse.of(items, Math.max(page, 0), limit, mapper.countVisitors(eventId));
}
// SCR-32 리드(마스킹 + AI 저장값)
public PageResponse<LeadItem> leads(String eventId, String boothId, int page, int size) {
int limit = clamp(size, 1, 200);
int offset = Math.max(page, 0) * limit;
String bid = (boothId == null || boothId.isBlank()) ? null : boothId;
List<Map<String, Object>> rows = mapper.findLeadPage(eventId, bid, limit, offset);
List<LeadItem> items = new ArrayList<>(rows == null ? 0 : rows.size());
if (rows != null) {
for (Map<String, Object> r : rows) {
items.add(new LeadItem(
str(r.get("id")), str(r.get("nameMasked")), str(r.get("role")), str(r.get("company")),
str(r.get("product")), intVal(r.get("interest")), intVal(r.get("score")),
str(r.get("collectedAt")), str(r.get("phoneMasked")), str(r.get("emailMasked")),
parseStrings(r.get("aiReasons")), parseActivity(r.get("activity")),
str(r.get("followupDraft")), bool(r.get("aiGenerated"))));
}
}
return PageResponse.of(items, Math.max(page, 0), limit, mapper.countLeads(eventId, bid));
}
// 공개 사전등록
public RegisterResult register(String eventId, RegisterRequest req) {
String name = trimOrNull(req.name());
String email = trimOrNull(req.email());
String phone = trimOrNull(req.phone());
if (name == null || email == null || !email.contains("@")) {
throw new ApiException(ErrorCode.VALIDATION, "이름과 유효한 이메일을 입력해 주세요.");
}
if (req.agreePrivacy() == null || !req.agreePrivacy()) {
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용에 동의해야 등록할 수 있습니다.");
}
if (mapper.eventExists(eventId) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다.");
}
// 중복 이메일 기존 등록 재반환(멱등)
Map<String, Object> existing = mapper.findByEventEmail(eventId, email);
if (existing != null && existing.get("registrationId") != null) {
return new RegisterResult(str(existing.get("registrationId")), str(existing.get("badgeCode")));
}
String type = normalizeType(req.visitorType());
String id = "vr-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20);
String badgeCode = "KTX-V-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
try {
mapper.insertRegistration(id, eventId, name, phone, email, type,
Boolean.TRUE.equals(req.agreePrivacy()), Boolean.TRUE.equals(req.agreeMarketing()), badgeCode);
} catch (org.springframework.dao.DuplicateKeyException dup) {
// 동시 요청 경합 기존 등록 재조회
Map<String, Object> again = mapper.findByEventEmail(eventId, email);
if (again != null && again.get("registrationId") != null) {
return new RegisterResult(str(again.get("registrationId")), str(again.get("badgeCode")));
}
throw new ApiException(ErrorCode.CONFLICT, "이미 등록된 이메일입니다.");
}
return new RegisterResult(id, badgeCode);
}
// helpers
private static String normalizeType(String t) {
if (t == null) return "visitor";
String v = t.trim().toLowerCase();
return switch (v) {
case "buyer", "vip", "visitor" -> v;
default -> "visitor";
};
}
private List<String> parseStrings(Object json) {
if (json == null) return List.of();
try {
return JSON.readValue(String.valueOf(json), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {
});
} catch (Exception e) {
return List.of();
}
}
private List<Activity> parseActivity(Object json) {
if (json == null) return List.of();
try {
List<Map<String, Object>> raw = JSON.readValue(String.valueOf(json),
new com.fasterxml.jackson.core.type.TypeReference<List<Map<String, Object>>>() {
});
List<Activity> out = new ArrayList<>(raw.size());
for (Map<String, Object> m : raw) {
out.add(new Activity(str(m.get("text")), str(m.get("at"))));
}
return out;
} catch (Exception e) {
return List.of();
}
}
private static Map<String, Object> orEmpty(Map<String, Object> m) {
return m == null ? Map.of() : m;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static String trimOrNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
private static long lng(Object o) {
if (o == null) return 0;
if (o instanceof Number n) return n.longValue();
try {
return Long.parseLong(String.valueOf(o));
} catch (NumberFormatException e) {
return 0;
}
}
private static int intVal(Object o) {
return (int) lng(o);
}
private static boolean bool(Object o) {
return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
}
private static int clamp(int v, int min, int max) {
return Math.max(min, Math.min(v, max));
}
private static String fmt(long v) {
return String.format("%,d", v);
}
private static String pct(long part, long whole) {
if (whole <= 0) return "0%";
return Math.round(part * 100.0 / whole) + "%";
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.kintex.visitor.dto;
import java.util.List;
/**
* M10 관람객·리드 응답 DTO 모음.
* <p> PII 불변(N2/R10): 리스트/상세 응답에는 마스킹 필드({@code nameMasked}·{@code phoneMasked}·{@code emailMasked})
* 존재하며 원문 name/phone/email 필드는 존재하지 않는다. 마스킹은 매퍼 SQL 표현식이 수행한다.
*/
public final class VisitorDtos {
private VisitorDtos() {
}
// SCR-30 등록 대시보드 요약
public record VisitorSummary(List<Kpi> kpis, List<TrendPoint> trend, List<TypeSlice> types, List<FormItem> forms) {
}
public record Kpi(String label, String value, String delta, String trend, Boolean ai, String sub) {
}
public record TrendPoint(String label, long preReg, long checkIn) {
}
public record TypeSlice(String name, int value, String color) {
}
public record FormItem(String id, String name, boolean active, long count) {
}
// SCR-30 등록자 목록(마스킹)
public record VisitorListItem(String id, String nameMasked, String type, String company,
String registeredAt, String checkin, boolean badgeIssued) {
}
// SCR-32 리드(마스킹 + AI 저장값)
public record LeadItem(String id, String nameMasked, String role, String company, String product,
int interest, int score, String collectedAt, String phoneMasked, String emailMasked,
List<String> aiReasons, List<Activity> activity, String followupDraft, boolean aiGenerated) {
}
public record Activity(String text, String at) {
}
// 공개 사전등록 요청/결과
public record RegisterRequest(String name, String phone, String email, String visitorType,
Boolean agreePrivacy, Boolean agreeMarketing) {
}
public record RegisterResult(String registrationId, String badgeCode) {
}
}

View File

@ -0,0 +1,72 @@
-- =====================================================================
-- V14 M6 서류·마일스톤 (SCR-22/23) — event_milestone · required_document · document_review_issue
-- 멱등: CREATE TABLE IF NOT EXISTS + 시드 ON CONFLICT DO NOTHING.
-- 참조 컨벤션: event(id)·hall 등 snake_case, FK ON DELETE CASCADE.
-- 시드는 기존 실행사 e-2026-smf(2026 스마트팩토리 코리아, 개장 2026-08-11)를 참조.
-- V1~V13 불변 · 이 파일은 V14 단일 버전만 사용.
-- =====================================================================
-- 전시 마일스톤(행사별 표준 일정 노드) -----------------------------------
CREATE TABLE IF NOT EXISTS event_milestone (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
milestone_type varchar(30) NOT NULL, -- assignment|pre_review|utility|documents|opening
label varchar(80) NOT NULL,
due_date date,
state varchar(20) NOT NULL DEFAULT 'todo', -- done|active|todo
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (event_id, milestone_type)
);
CREATE INDEX IF NOT EXISTS idx_event_milestone_event ON event_milestone(event_id);
-- 신고서류 체크리스트(행사별 필수 서류) ----------------------------------
CREATE TABLE IF NOT EXISTS required_document (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
doc_type varchar(60) NOT NULL, -- operation_plan|booth_layout|disaster_plan|...
name varchar(120) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending', -- pending|draft|submitted|approved|rejected
due_date date,
sort_order integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (event_id, doc_type)
);
CREATE INDEX IF NOT EXISTS idx_required_document_event ON required_document(event_id);
-- AI 서류 검수 이슈(행사별 불일치/누락 경고) -----------------------------
CREATE TABLE IF NOT EXISTS document_review_issue (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
tone varchar(10) NOT NULL DEFAULT 'info', -- warn|info
title varchar(120) NOT NULL,
description varchar(400),
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_document_review_issue_event ON document_review_issue(event_id);
-- ── 시드: e-2026-smf (Stitch SCR-22 정합) ──────────────────────────────
INSERT INTO event_milestone (id, event_id, milestone_type, label, due_date, state, sort_order) VALUES
('ms-smf-assign', 'e-2026-smf', 'assignment', 'D-150 배정', DATE '2026-03-14', 'done', 0),
('ms-smf-prereview','e-2026-smf', 'pre_review', 'D-30 사전협의', DATE '2026-07-12', 'done', 1),
('ms-smf-utility', 'e-2026-smf', 'utility', 'D-25 유틸리티', DATE '2026-07-17', 'done', 2),
('ms-smf-docs', 'e-2026-smf', 'documents', 'D-7 신고서류', DATE '2026-08-04', 'active', 3),
('ms-smf-opening', 'e-2026-smf', 'opening', 'D-0 개장', DATE '2026-08-11', 'todo', 4)
ON CONFLICT (event_id, milestone_type) DO NOTHING;
INSERT INTO required_document (id, event_id, doc_type, name, status, due_date, sort_order) VALUES
('doc-smf-op', 'e-2026-smf', 'operation_plan', '행사운영계획서', 'approved', NULL, 0),
('doc-smf-layout', 'e-2026-smf', 'booth_layout', '부스배치도', 'submitted', NULL, 1),
('doc-smf-disaster','e-2026-smf', 'disaster_plan', '재해대처계획서', 'draft', DATE '2026-08-04', 2),
('doc-smf-rigging', 'e-2026-smf', 'rigging_calc', '리깅 구조계산서', 'rejected', DATE '2026-08-04', 3),
('doc-smf-fire', 'e-2026-smf', 'fire_safety', '방화관리 책임서약서', 'pending', DATE '2026-08-04', 4),
('doc-smf-parking', 'e-2026-smf', 'parking', '주차관리 신청서', 'pending', DATE '2026-08-04', 5),
('doc-smf-security','e-2026-smf', 'security', '보안요원 배치계획', 'pending', DATE '2026-08-04', 6),
('doc-smf-hazmat', 'e-2026-smf', 'hazmat', '위험물 반입신고서', 'pending', DATE '2026-08-04', 7)
ON CONFLICT (event_id, doc_type) DO NOTHING;
INSERT INTO document_review_issue (id, event_id, tone, title, description, sort_order) VALUES
('dri-smf-1', 'e-2026-smf', 'warn', '데이터 불일치 감지', '부스배치도 부스 수 486 ≠ 운영계획서 510 (불일치)', 0),
('dri-smf-2', 'e-2026-smf', 'info', '필수요소 누락', '재해대처계획서 필수요소 누락 2건', 1)
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,68 @@
-- =====================================================================
-- V15 M8 반입/반출 도크 슬롯 예약 (SCR-25) — dock · dock_reservation
-- 멱등: CREATE TABLE IF NOT EXISTS + 시드 ON CONFLICT DO NOTHING.
-- dock 은 하역장 물리 도크(홀 귀속), dock_reservation 은 행사·방향·일자·시간대 슬롯.
-- 슬롯 중복(같은 도크·일자·방향의 시간대 겹침)은 서비스에서 409 CONFLICT 판정.
-- 시드는 e-2026-smf(홀 H7, 반입일 2026-08-11 / 반출일 2026-08-15) 참조.
-- V1~V13 불변 · 이 파일은 V15 단일 버전만 사용.
-- =====================================================================
-- 하역장 도크 마스터(홀별) ----------------------------------------------
CREATE TABLE IF NOT EXISTS dock (
id varchar(40) PRIMARY KEY,
hall_id varchar(20) NOT NULL REFERENCES hall(id),
dock_no integer NOT NULL,
label varchar(40) NOT NULL,
heavy_priority boolean NOT NULL DEFAULT false, -- 중량물(5t↑) 우선 도크
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (hall_id, dock_no)
);
CREATE INDEX IF NOT EXISTS idx_dock_hall ON dock(hall_id);
-- 도크 예약(반입/반출 슬롯) ---------------------------------------------
CREATE TABLE IF NOT EXISTS dock_reservation (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
dock_id varchar(40) NOT NULL REFERENCES dock(id),
direction varchar(4) NOT NULL, -- in|out
reserve_date date NOT NULL,
start_hour integer NOT NULL, -- 8..19 (그리드 08:00~19:00)
span integer NOT NULL DEFAULT 2, -- 점유 시간대 수
company_name varchar(120),
vehicle_no varchar(30),
vehicle_weight varchar(20),
item varchar(200),
forklift boolean NOT NULL DEFAULT false,
tone varchar(10) NOT NULL DEFAULT 'booked', -- booked|priority
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dock_reservation_lookup
ON dock_reservation(event_id, direction, reserve_date);
-- ── 시드: 홀 H7 도크 6개 ───────────────────────────────────────────────
INSERT INTO dock (id, hall_id, dock_no, label, heavy_priority) VALUES
('dock-h7-1', 'H7', 1, '도크 1', true),
('dock-h7-2', 'H7', 2, '도크 2', false),
('dock-h7-3', 'H7', 3, '도크 3', false),
('dock-h7-4', 'H7', 4, '도크 4', false),
('dock-h7-5', 'H7', 5, '도크 5', false),
('dock-h7-6', 'H7', 6, '도크 6', false)
ON CONFLICT (hall_id, dock_no) DO NOTHING;
-- ── 시드: 반입(2026-08-11) 예약 (Stitch SCR-25 정합) ───────────────────
INSERT INTO dock_reservation
(id, event_id, dock_id, direction, reserve_date, start_hour, span, company_name, vehicle_no, vehicle_weight, item, forklift, tone) VALUES
('dr-smf-in-1', 'e-2026-smf', 'dock-h7-1', 'in', DATE '2026-08-11', 8, 2, '(주)공간디자인', '12가 3456', '5t', '전시 부스 자재', true, 'booked'),
('dr-smf-in-2', 'e-2026-smf', 'dock-h7-1', 'in', DATE '2026-08-11', 11, 2, NULL, NULL, '5t', '중량물 우선(5t↑)', false, 'priority'),
('dr-smf-in-3', 'e-2026-smf', 'dock-h7-2', 'in', DATE '2026-08-11', 12, 3, '글로벌부스테크', '34나 5678', '2.5t', '조립식 부스', false, 'booked'),
('dr-smf-in-4', 'e-2026-smf', 'dock-h7-3', 'in', DATE '2026-08-11', 9, 2, '비욘드디자인', '56다 7890', '1t', '전시 소품', false, 'booked'),
('dr-smf-in-5', 'e-2026-smf', 'dock-h7-5', 'in', DATE '2026-08-11', 16, 2, '네오로지스', '78라 1234', '5t', '전시 장비', true, 'booked')
ON CONFLICT (id) DO NOTHING;
-- ── 시드: 반출(2026-08-15) 예약 ────────────────────────────────────────
INSERT INTO dock_reservation
(id, event_id, dock_id, direction, reserve_date, start_hour, span, company_name, vehicle_no, vehicle_weight, item, forklift, tone) VALUES
('dr-smf-out-1', 'e-2026-smf', 'dock-h7-1', 'out', DATE '2026-08-15', 14, 3, '(주)공간디자인', '12가 3456', '5t', '철거 자재', true, 'booked'),
('dr-smf-out-2', 'e-2026-smf', 'dock-h7-3', 'out', DATE '2026-08-15', 15, 2, NULL, NULL, '5t', '중량물 우선(5t↑)', false, 'priority'),
('dr-smf-out-3', 'e-2026-smf', 'dock-h7-4', 'out', DATE '2026-08-15', 17, 3, '네오로지스', '78라 1234', '11t 이상', '철거 장비', true, 'booked')
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,122 @@
-- 킨텍스 — M15 공사/장치 옥션(역경매) 스키마 + 시드 (멱등)
-- PLANNING §5 M15: Auction 1─N Bid(=Quotation) ─ Award. 등록업체(company.registered)만 응찰(서버 게이트).
-- 봉인 입찰: 마감 전 경쟁 견적 비공개(서비스 레이어에서 강제 마스킹), 마감 후 발주자만 전체 공개.
-- 보안 불변: 본 마이그레이션은 V16 단독. V1~V13 및 타 팀 마이그레이션(V14/V15/V17~)을 변경하지 않는다.
-- 옥션(공고) ------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS auction (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
title varchar(200) NOT NULL,
category varchar(40) NOT NULL, -- 전시디자인설치|구조물 임대|전기/조명|영상/음향|네트워크
auction_type varchar(20) NOT NULL DEFAULT 'reverse', -- reverse(역경매)|rfq
award_criteria varchar(20) NOT NULL DEFAULT 'comprehensive', -- lowest|comprehensive
weight_price integer NOT NULL DEFAULT 60,
weight_reputation integer NOT NULL DEFAULT 25,
weight_delivery integer NOT NULL DEFAULT 15,
round integer NOT NULL DEFAULT 1,
deadline timestamptz NOT NULL,
material_package_id varchar(40), -- M2~M5 AI 자료 패키지 소프트 참조
materials varchar(120) NOT NULL DEFAULT '', -- 콤마구분: layout,design,boq,aiimage
accent varchar(20) NOT NULL DEFAULT 'primary',
created_by varchar(40),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_auction_event ON auction(event_id);
-- 초대(등록업체만) — 미등록 업체는 초대·응찰 원천 차단 -------------------------
CREATE TABLE IF NOT EXISTS auction_invite (
id varchar(40) PRIMARY KEY,
auction_id varchar(40) NOT NULL REFERENCES auction(id) ON DELETE CASCADE,
company_id varchar(40) NOT NULL REFERENCES company(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (auction_id, company_id)
);
-- 응찰 = 견적서(Quotation). (auction,company) 당 1행 — 재응찰 시 버전 증가·갱신 --
CREATE TABLE IF NOT EXISTS bid (
id varchar(40) PRIMARY KEY,
auction_id varchar(40) NOT NULL REFERENCES auction(id) ON DELETE CASCADE,
company_id varchar(40) NOT NULL REFERENCES company(id),
bidder_user_id varchar(40),
round integer NOT NULL DEFAULT 1,
subtotal bigint NOT NULL DEFAULT 0,
vat bigint NOT NULL DEFAULT 0,
total bigint NOT NULL,
lead_days integer NOT NULL DEFAULT 0,
valid_until date,
terms text,
lines jsonb, -- 라인아이템(공종·자재·수량·단가) 스냅샷
version integer NOT NULL DEFAULT 1,
status varchar(20) NOT NULL DEFAULT 'submitted',
submitted_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (auction_id, company_id)
);
CREATE INDEX IF NOT EXISTS idx_bid_auction ON bid(auction_id);
-- 낙찰(Award) — 옥션당 1건. 감사 추적(사유·발주자·시각) --------------------------
CREATE TABLE IF NOT EXISTS award (
id varchar(40) PRIMARY KEY,
auction_id varchar(40) NOT NULL UNIQUE REFERENCES auction(id) ON DELETE CASCADE,
bid_id varchar(40) NOT NULL REFERENCES bid(id),
reason text NOT NULL,
awarded_by varchar(40),
awarded_at timestamptz NOT NULL DEFAULT now()
);
-- 업체 평판(종합평가 스코어 입력) — 초기 규정준수 이력·자기신고, 축적 후 시공 평점 --
CREATE TABLE IF NOT EXISTS company_reputation (
company_id varchar(40) PRIMARY KEY REFERENCES company(id) ON DELETE CASCADE,
rating numeric(3,1) NOT NULL DEFAULT 4.0, -- 5점 만점
jobs_done integer NOT NULL DEFAULT 0,
claim_rate numeric(5,2) NOT NULL DEFAULT 0 -- 클레임 발생률(%)
);
-- ===== 시드(멱등) — 기존 event(e-2026-smf)·company(co-000x) 참조 =================
INSERT INTO company_reputation (company_id, rating, jobs_done, claim_rate) VALUES
('co-0001', 4.8, 12, 0.0),
('co-0002', 4.6, 8, 1.5),
('co-0003', 4.2, 15, 3.0),
('co-0004', 4.0, 5, 2.0)
ON CONFLICT (company_id) DO NOTHING;
-- 옥션 3건: 진행중(종합)·진행중(최저가)·정산중(낙찰 완료) --------------------------
INSERT INTO auction (id, event_id, title, category, auction_type, award_criteria,
weight_price, weight_reputation, weight_delivery, round, deadline,
material_package_id, materials, accent) VALUES
('AUC-2026-0102', 'e-2026-smf', 'A-102 독립부스 시공', '전시디자인설치', 'reverse', 'comprehensive',
60, 25, 15, 2, now() + interval '2 day', 'mp-a102', 'layout,design,boq,aiimage', 'primary'),
('AUC-2026-0405', 'e-2026-smf', 'B-405 트러스 구조물 설치', '구조물 임대', 'reverse', 'lowest',
100, 0, 0, 1, now() + interval '1 day', NULL, 'layout,boq', 'secondary'),
('AUC-2026-0210', 'e-2026-smf', 'C-10 공용 통로 조명 공사', '전기/조명', 'reverse', 'comprehensive',
50, 30, 20, 1, now() - interval '3 day', NULL, 'layout,boq', 'tertiary')
ON CONFLICT (id) DO NOTHING;
INSERT INTO auction_invite (id, auction_id, company_id) VALUES
('ai-0102-1', 'AUC-2026-0102', 'co-0001'),
('ai-0102-2', 'AUC-2026-0102', 'co-0002'),
('ai-0102-3', 'AUC-2026-0102', 'co-0003'),
('ai-0405-1', 'AUC-2026-0405', 'co-0002'),
('ai-0405-2', 'AUC-2026-0405', 'co-0003'),
('ai-0210-1', 'AUC-2026-0210', 'co-0003')
ON CONFLICT (auction_id, company_id) DO NOTHING;
INSERT INTO bid (id, auction_id, company_id, round, subtotal, vat, total, lead_days) VALUES
-- AUC-0102 (진행중·종합) — 최저가 co-0001 8.4M
('bid-0102-1', 'AUC-2026-0102', 'co-0001', 2, 8400000, 840000, 8400000, 12),
('bid-0102-2', 'AUC-2026-0102', 'co-0002', 2, 8700000, 870000, 8700000, 10),
('bid-0102-3', 'AUC-2026-0102', 'co-0003', 1, 8900000, 890000, 8900000, 14),
-- AUC-0405 (진행중·최저가)
('bid-0405-1', 'AUC-2026-0405', 'co-0002', 1, 4250000, 425000, 4250000, 8),
('bid-0405-2', 'AUC-2026-0405', 'co-0003', 1, 4600000, 460000, 4600000, 9),
-- AUC-0210 (정산중·낙찰) — 낙찰 co-0003 12M
('bid-0210-1', 'AUC-2026-0210', 'co-0003', 1, 12000000, 1200000, 12000000, 20),
('bid-0210-2', 'AUC-2026-0210', 'co-0001', 1, 12800000, 1280000, 12800000, 18)
ON CONFLICT (auction_id, company_id) DO NOTHING;
INSERT INTO award (id, auction_id, bid_id, reason, awarded_by) VALUES
('aw-0210', 'AUC-2026-0210', 'bid-0210-1',
'최저가 응찰 + 최근 3년 유사 규모 시공 실적 우수(종합평가 1위).', 'system-seed')
ON CONFLICT (auction_id) DO NOTHING;

View File

@ -0,0 +1,91 @@
-- V17: M10 관람객 등록 · 리드 (사전등록·배지·리드 스코어링)
-- 멱등(IF NOT EXISTS / ON CONFLICT DO NOTHING). 기존 마이그레이션(V1~V13) 불변.
-- ★ 개인정보(name/phone/email)는 원문 저장하되 API 응답에서는 매퍼가 마스킹만 노출한다(N2/R10).
-- 원문 컬럼은 조회 SQL 의 SELECT 목록에 절대 포함하지 않는다(마스킹 표현식만 반환).
-- ── 관람객 사전등록 (SCR-30) ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS visitor_registration (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
name varchar(120) NOT NULL, -- 원문(응답 금지 · 마스킹만 노출)
phone varchar(40), -- 원문(응답 금지)
email varchar(200), -- 원문(응답 금지)
company varchar(200),
visitor_type varchar(20) NOT NULL DEFAULT 'visitor', -- visitor|buyer|vip
agree_privacy boolean NOT NULL DEFAULT false,
agree_marketing boolean NOT NULL DEFAULT false,
badge_code varchar(40) NOT NULL, -- 배지/QR 코드(민감정보 아님)
badge_issued boolean NOT NULL DEFAULT false,
checkin_state varchar(20) NOT NULL DEFAULT 'waiting', -- done|waiting|cancelled
registered_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
-- 이메일 중복 등록 방지(행사 단위) — register 재요청 시 기존 등록 재반환 근거.
CREATE UNIQUE INDEX IF NOT EXISTS uq_visitor_reg_event_email
ON visitor_registration (event_id, lower(email)) WHERE email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_visitor_reg_event ON visitor_registration (event_id);
CREATE INDEX IF NOT EXISTS idx_visitor_reg_regdate ON visitor_registration (event_id, registered_at);
-- ── 리드 (SCR-32, 참가업체·부스 방문 리드) ──────────────────────────────────
CREATE TABLE IF NOT EXISTS lead (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
booth_id varchar(40), -- 부스 스코프(선택 · booth 시드 전엔 NULL 허용)
name varchar(120) NOT NULL, -- 원문(응답 금지)
phone varchar(40), -- 원문(응답 금지)
email varchar(200), -- 원문(응답 금지)
role varchar(120),
company varchar(200),
product varchar(200),
interest integer NOT NULL DEFAULT 3, -- 1..5 관심도
score integer NOT NULL DEFAULT 0, -- 0..100 AI 저장 스코어
ai_reasons jsonb NOT NULL DEFAULT '[]'::jsonb,
activity jsonb NOT NULL DEFAULT '[]'::jsonb,
followup_draft text,
ai_generated boolean NOT NULL DEFAULT true,
collected_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lead_event ON lead (event_id);
CREATE INDEX IF NOT EXISTS idx_lead_event_booth ON lead (event_id, booth_id);
CREATE INDEX IF NOT EXISTS idx_lead_score ON lead (event_id, score DESC);
-- ── 시드(기존 event 참조 · 멱등) ────────────────────────────────────────────
-- 관람객 사전등록 데모(원문은 데모용 가명 — 마스킹 표현식 검증 목적).
INSERT INTO visitor_registration
(id, event_id, name, phone, email, company, visitor_type, agree_privacy, agree_marketing, badge_code, badge_issued, checkin_state, registered_at)
VALUES
('vr-smf-001','e-2026-smf','김서희','010-1234-9910','seohee.kim@lg.com','LG전자','visitor',true,true,'KTX-V-0001',true,'done', TIMESTAMPTZ '2026-08-01 09:10+09'),
('vr-smf-002','e-2026-smf','이도준','010-2233-1188','dojun.lee@samsung.com','삼성전자','buyer',true,true,'KTX-V-0002',true,'waiting',TIMESTAMPTZ '2026-08-03 10:20+09'),
('vr-smf-003','e-2026-smf','박민서','010-8890-4412','minseo.park@hyundai.com','현대자동차','vip',true,false,'KTX-V-0003',true,'done', TIMESTAMPTZ '2026-08-04 11:05+09'),
('vr-smf-004','e-2026-smf','최상훈','010-5566-2030','sanghoon.choi@indiv.kr',NULL,'visitor',true,false,'KTX-V-0004',false,'cancelled',TIMESTAMPTZ '2026-08-04 14:40+09'),
('vr-smf-005','e-2026-smf','정유아','010-7712-6654','yua.jung@future-tech.co.kr','(주)미래기술','buyer',true,true,'KTX-V-0005',true,'done',TIMESTAMPTZ '2026-08-05 09:55+09'),
('vr-smf-006','e-2026-smf','한지수','010-3341-7788','jisu.han@doosan.com','두산로보틱스','visitor',true,true,'KTX-V-0006',true,'waiting',TIMESTAMPTZ '2026-08-05 15:30+09'),
('vr-smf-007','e-2026-smf','오세연','010-9922-1043','seyeon.oh@nextinno.com','넥스트이노베이션','buyer',true,true,'KTX-V-0007',true,'done',TIMESTAMPTZ '2026-08-06 09:15+09'),
('vr-smf-008','e-2026-smf','신재원','010-6604-3399','jaewon.shin@globaltech.io','글로벌테크','visitor',true,false,'KTX-V-0008',false,'waiting',TIMESTAMPTZ '2026-08-06 13:12+09')
ON CONFLICT (id) DO NOTHING;
INSERT INTO lead
(id, event_id, booth_id, name, phone, email, role, company, product, interest, score, ai_reasons, activity, followup_draft, ai_generated, collected_at)
VALUES
('ld-smf-001','e-2026-smf',NULL,'김태훈','010-1120-3021','taehoon.kim@techsol.co.kr','구매 담당자','(주)테크솔루션','협동로봇 HR-A1',5,87,
'["과거 유사 전시회 로봇 구매 이력 있음","당사 부스 체류 시간 평균 대비 240% 높음","기술 사양서(PDF) 다운로드 2회 기록"]'::jsonb,
'[{"text":"제품 카탈로그 QR 스캔","at":"오늘 14:22"},{"text":"부스 내 HR-A1 구동 시연 참관","at":"오늘 14:15"}]'::jsonb,
'안녕하세요, (주)테크솔루션 김 팀장님. KINTEX 부스에 방문해주셔서 감사합니다. 관심을 보이셨던 협동로봇 HR-A1의 상세 기술 사양과 커스텀 제안서를 준비했습니다. 다음 주 중 짧은 미팅이 가능하실까요?',true, TIMESTAMPTZ '2026-08-11 14:22+09'),
('ld-smf-002','e-2026-smf',NULL,'이하늘','010-3300-7742','haneul.lee@nextinno.com','기술 기획','넥스트이노베이션','스마트 물류 시스템',3,65,
'["물류 자동화 세션 참석 이력","부스 체류 시간 평균 수준"]'::jsonb,
'[{"text":"스마트 물류 데모 영상 시청","at":"오늘 14:15"},{"text":"브로슈어 다운로드","at":"오늘 14:03"}]'::jsonb,
'안녕하세요, 넥스트이노베이션 이 님. 스마트 물류 시스템에 관심 가져주셔서 감사합니다. 도입 사례집과 ROI 시뮬레이션 자료를 보내드립니다.',true, TIMESTAMPTZ '2026-08-11 14:15+09'),
('ld-smf-003','e-2026-smf',NULL,'박연구','010-4400-1188','park@fmlab.re.kr','연구소장','미래제조연구소','AI 비전 검사 모듈',4,82,
'["AI 비전 검사 데모 3회 재방문","구매 결정권자(연구소장) 직급","견적 요청 폼 작성 완료"]'::jsonb,
'[{"text":"견적 요청 폼 제출","at":"오늘 14:02"},{"text":"AI 비전 모듈 상세 상담","at":"오늘 13:50"}]'::jsonb,
'안녕하세요, 미래제조연구소 박 소장님. 요청하신 AI 비전 검사 모듈 견적서를 첨부드립니다. 파일럿 도입 프로그램도 안내드리겠습니다.',true, TIMESTAMPTZ '2026-08-11 14:02+09'),
('ld-smf-004','e-2026-smf',NULL,'정설비','010-5500-9003','jung@globaltech.io','설비 담당','글로벌테크','자율주행 AMR',2,44,
'["AMR 부스 단순 방문","자료 열람 이력 없음"]'::jsonb,
'[{"text":"부스 QR 스캔","at":"오늘 13:55"}]'::jsonb,
'안녕하세요, 글로벌테크 정 님. 자율주행 AMR 소개 자료를 보내드립니다. 궁금하신 점이 있으시면 언제든 문의해 주세요.',true, TIMESTAMPTZ '2026-08-11 13:55+09'),
('ld-smf-005','e-2026-smf',NULL,'강엔지','010-6600-2250','kang@sctsys.kr','인프라 엔지니어','SCT시스템','클라우드 모니터링',1,28,
'["짧은 부스 체류","경쟁사 부스 위주 관람"]'::jsonb,
'[{"text":"부스 QR 스캔","at":"오늘 13:48"}]'::jsonb,
'안녕하세요, SCT시스템 강 님. 클라우드 모니터링 솔루션 소개서를 보내드립니다.',true, TIMESTAMPTZ '2026-08-11 13:48+09')
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,76 @@
-- V18: M12 EDM·캠페인 · 스폰서십 패키지/판매
-- 멱등(IF NOT EXISTS / ON CONFLICT DO NOTHING). 기존 마이그레이션 불변.
-- 실 발송은 미구현 — 캠페인은 status 전이만(발송 게이트웨이 연동은 후속 갭).
-- ── EDM·캠페인 (SCR-33) ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS edm_campaign (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
name varchar(300) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'draft', -- draft|scheduled|sending|done
audience integer NOT NULL DEFAULT 0,
meta varchar(300), -- 표시용 부가정보(최종수정·발송예정 등)
open_rate varchar(40) NOT NULL DEFAULT '-',
click_rate varchar(40) NOT NULL DEFAULT '-',
scheduled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_edm_campaign_event ON edm_campaign (event_id, created_at DESC);
-- ── 스폰서십 패키지 (SCR-34, F070) ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS sponsorship_package (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
code varchar(30) NOT NULL, -- diamond|gold|silver
name varchar(60) NOT NULL, -- DIAMOND/GOLD/SILVER
name_ko varchar(60) NOT NULL,
price bigint NOT NULL DEFAULT 0, -- KRW
accent varchar(20),
benefits jsonb NOT NULL DEFAULT '[]'::jsonb,
total_qty integer NOT NULL DEFAULT 0,
sold_qty integer NOT NULL DEFAULT 0,
dday integer,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sponsorship_pkg_event ON sponsorship_package (event_id, sort_order);
-- ── 스폰서 계약/판매 (SCR-34) ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS sponsorship_sponsor (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
package_id varchar(40) REFERENCES sponsorship_package(id) ON DELETE SET NULL,
name varchar(200) NOT NULL, -- 기업명(민감정보 아님)
tier_label varchar(80),
contract_status varchar(20) NOT NULL DEFAULT 'pending', -- signed|pending
fulfillment jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sponsorship_sponsor_event ON sponsorship_sponsor (event_id);
-- ── 시드(기존 event 참조 · 멱등) ────────────────────────────────────────────
INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate) VALUES
('cp-smf-001','e-2026-smf','2026 스마트팩토리 사전등록 안내','draft', 5200, '최종 수정: 2시간 전', '-', '-'),
('cp-smf-002','e-2026-smf','VIP 바이어 네트워킹 데이 초대권','scheduled', 850, '발송 예정: 2026.08.05 10:00', '24% (예상)','5% (예상)'),
('cp-smf-003','e-2026-smf','글로벌 테크 트렌드 리포트 (Vol. 12)','sending', 12000,'진행률: 68% (8,160건 완료)', '42.5%', '11.2%'),
('cp-smf-004','e-2026-smf','KINTEX 개막 D-7 프로그램 안내','done', 45000, '완료일: 2026.08.04', '31.8%', '2.4%')
ON CONFLICT (id) DO NOTHING;
INSERT INTO sponsorship_package (id, event_id, code, name, name_ko, price, accent, benefits, total_qty, sold_qty, dday, sort_order) VALUES
('sp-smf-diamond','e-2026-smf','diamond','DIAMOND','다이아몬드',50000000,'#B8860B',
'["메인 로고 노출 (온·오프라인)","전시 부스 100㎡ (최우선 배정)","연사 세션 2회 부여"]'::jsonb, 2, 1, 12, 1),
('sp-smf-gold','e-2026-smf','gold','GOLD','골드',30000000,'#667085',
'["서브 로고 노출","전시 부스 50㎡","연사 세션 1회"]'::jsonb, 3, 3, NULL, 2),
('sp-smf-silver','e-2026-smf','silver','SILVER','실버',15000000,'#8B4513',
'["일반 로고 노출","전시 부스 20㎡"]'::jsonb, 5, 2, NULL, 3)
ON CONFLICT (id) DO NOTHING;
INSERT INTO sponsorship_sponsor (id, event_id, package_id, name, tier_label, contract_status, fulfillment) VALUES
('ss-smf-001','e-2026-smf','sp-smf-diamond','(주)테크솔루션','다이아몬드 패키지','signed',
'[{"id":"f1","label":"공식 홈페이지 로고 노출","done":true},{"id":"f2","label":"전시 홀 부스 배정 (A-101)","done":true},{"id":"f3","label":"연사 세션 주제 선정","done":false},{"id":"f4","label":"브로슈어 광고 인쇄","done":false}]'::jsonb),
('ss-smf-002','e-2026-smf','sp-smf-gold','글로벌바이오','골드 패키지','pending',
'[{"id":"f1","label":"공식 홈페이지 로고 노출","done":true},{"id":"f2","label":"전시 홀 부스 배정","done":false}]'::jsonb),
('ss-smf-003','e-2026-smf','sp-smf-silver','넥스트인더스트리','실버 패키지','signed',
'[{"id":"f1","label":"공식 홈페이지 로고 노출","done":true},{"id":"f2","label":"전시 홀 부스 배정 (C-210)","done":true}]'::jsonb)
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,90 @@
-- V19: M17 CMS — 콘텐츠·게시 워크플로 · 다국어 번역 · 참가업체 마이크로사이트 (멱등 · V1~V13 불변)
-- 근거: _workspace/port_cms.md(화면 계약) + 할 일(엔드포인트 계약).
-- cms_content : 헤드리스 콘텐츠(페이지/포스트/블록) + 게시 상태(draft→review→approved→published) + 예약게시.
-- cms_translation : 콘텐츠×언어(ko/en/zh/ja) 번역본 + 번역 상태(none/ai/reviewed).
-- microsite : 참가업체별 공개 소개 페이지(섹션 JSONB·테마·SEO) — 관람객 공개 조회(/api/public/microsites).
-- 신규 테이블만 추가(기존 스키마 무영향). 모든 DDL/시드 멱등.
-- ── 콘텐츠 ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_content (
id varchar(40) PRIMARY KEY,
event_id varchar(40), -- 행사 스코프(선택)
content_type varchar(20) NOT NULL DEFAULT 'PAGE', -- PAGE|POST|NOTICE|BLOCK
title varchar(300) NOT NULL,
body text, -- 블록/본문(JSON 또는 텍스트)
status varchar(20) NOT NULL DEFAULT 'draft', -- draft|review|approved|published
lang varchar(5) NOT NULL DEFAULT 'ko', -- 원천(소스) 언어
scheduled_at timestamptz, -- 예약 게시 시각(NULL=즉시/미예약)
signage boolean NOT NULL DEFAULT false, -- 현장 사이니지 동시 배포
mailing boolean NOT NULL DEFAULT false, -- 메일링 통보
author_id varchar(40),
author_name varchar(120),
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cms_content_event ON cms_content(event_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_cms_content_status ON cms_content(status);
-- ── 번역(콘텐츠×언어) ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cms_translation (
id varchar(40) PRIMARY KEY,
content_id varchar(40) NOT NULL REFERENCES cms_content(id) ON DELETE CASCADE,
lang varchar(5) NOT NULL, -- ko|en|zh|ja
title varchar(300),
body text,
trans_status varchar(10) NOT NULL DEFAULT 'none', -- none(미번역)|ai(AI 초벌)|reviewed(검수완료)
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (content_id, lang)
);
CREATE INDEX IF NOT EXISTS idx_cms_translation_content ON cms_translation(content_id);
-- ── 마이크로사이트(참가업체별) ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS microsite (
exhibitor_id varchar(40) PRIMARY KEY, -- 참가업체 식별자(company/booth 스코프)
event_id varchar(40),
slug varchar(80) UNIQUE, -- 공개 URL 서브도메인 슬러그
exhibitor_name varchar(200),
theme varchar(20) NOT NULL DEFAULT 'blue',
sections jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{id,type,visible,order,payload}]
seo_title varchar(300),
seo_meta text,
langs varchar(40) NOT NULL DEFAULT 'ko', -- 지원 언어 CSV
status varchar(20) NOT NULL DEFAULT 'draft', -- draft|published
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_microsite_event ON microsite(event_id);
-- ── 시드(멱등: ON CONFLICT DO NOTHING) ───────────────────────────────────────
INSERT INTO cms_content (id, event_id, content_type, title, body, status, lang, scheduled_at, signage, mailing, author_name)
VALUES
('cms-home', NULL, 'PAGE', '페이지', '전시 공식 홈 페이지 본문', 'published', 'ko', NULL, true, false, '관리자'),
('cms-notice', NULL, 'NOTICE', '공지사항', '반입·반출 안내 공지', 'draft', 'ko', NULL, false, false, '관리자'),
('cms-about', NULL, 'PAGE', '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개', 'review', 'ko', '2026-08-01 09:00+09', false, false, '관리자'),
('cms-speaker',NULL, 'POST', '연사 정보', '주요 연사 및 세션 안내', 'published', 'ko', NULL, false, true, '시스템'),
('cms-faq', NULL, 'PAGE', 'FAQ', '자주 묻는 질문', 'published', 'ko', NULL, false, false, '관리자')
ON CONFLICT (id) DO NOTHING;
INSERT INTO cms_translation (id, content_id, lang, title, body, trans_status)
VALUES
('tr-about-ko', 'cms-about', 'ko', '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개', 'reviewed'),
('tr-about-en', 'cms-about', 'en', 'Exhibition Overview', 'Overview of KINTEX Smart Factory Expo.', 'ai'),
('tr-about-zh', 'cms-about', 'zh', '展会介绍', '', 'none'),
('tr-about-ja', 'cms-about', 'ja', 'イベント紹介', '', 'none'),
('tr-home-ko', 'cms-home', 'ko', '페이지', '전시 공식 홈 페이지 본문', 'reviewed'),
('tr-home-en', 'cms-home', 'en', 'KINTEX Global Smart Tech Expo 2026', 'Official home page of the exhibition.', 'reviewed'),
('tr-notice-ko','cms-notice','ko', '공지사항', '반입·반출 안내 공지', 'reviewed'),
('tr-notice-en','cms-notice','en', '', '', 'none')
ON CONFLICT (content_id, lang) DO NOTHING;
INSERT INTO microsite (exhibitor_id, event_id, slug, exhibitor_name, theme, sections, seo_title, seo_meta, langs, status)
VALUES (
'hanbit', NULL, 'hanbit', '(주)한빛로보틱스', 'blue',
'[{"id":"intro","type":"intro","visible":true,"order":0,"payload":{"headline":"지능형 로보틱스의 새로운 지평을 열다","desc":"산업용 로봇부터 서비스 자동화 솔루션까지, 한빛로보틱스가 제시하는 미래 모빌리티 생태계를 경험하세요."}},{"id":"products","type":"products","visible":true,"order":1,"payload":{"items":[{"name":"HR-A1 협동로봇","tag":"NEW","desc":"고정밀 센서·AI 비전 시스템 탑재 다목적 협동로봇."},{"name":"ServiBot Elite","tag":"POPULAR","desc":"전시장·호텔용 지능형 서비스 로봇."}]}},{"id":"gallery","type":"gallery","visible":true,"order":2,"payload":{"images":[]}},{"id":"contact","type":"contact","visible":true,"order":3,"payload":{"email":"","phone":""}}]'::jsonb,
'한빛로보틱스 | KINTEX AI 전시',
'KINTEX AI EXPO (주)한빛로보틱스 공식 마이크로사이트 — 최첨단 협동로봇·서비스 로봇 솔루션을 만나보세요.',
'ko,en', 'published'
)
ON CONFLICT (exhibitor_id) DO NOTHING;

View File

@ -0,0 +1,37 @@
-- V20: 공개 홍보 사이트(M12) — 참가/부스 신청 문의 접수 테이블.
-- 근거: _workspace/port_public.md G6(참가 문의 리드) + POST /api/public/inquiries.
-- 범위: exhibit_inquiry 신규 테이블만(멱등) + 최소 시드. 기존 테이블/컬럼 불변.
-- 공개 영역: 비인증 접수. PII(email/phone)는 저장하되 공개 응답에는 노출하지 않는다(접수번호만 반환).
-- ── 참가/부스 신청 문의(리드) ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS exhibit_inquiry (
id varchar(40) PRIMARY KEY,
receipt_no varchar(30) NOT NULL UNIQUE, -- 공개 접수번호(KTX-INQ-YYYYMMDD-XXXX)
inquiry_type varchar(30), -- shell | raw | premium | general(부스 유형/문의 성격)
company varchar(200),
contact_name varchar(120),
email varchar(200), -- 접수 확인용(공개 응답 미노출)
phone varchar(40), -- (공개 응답 미노출)
scale varchar(60), -- 예상 규모
hall_pref varchar(40), -- 희망 홀
message text,
agree_privacy boolean NOT NULL DEFAULT false,
agree_marketing boolean NOT NULL DEFAULT false,
status varchar(20) NOT NULL DEFAULT 'received', -- received | in_review | closed
created_at timestamptz NOT NULL DEFAULT now()
);
-- 스팸/rate 제어(동일 이메일 1분 1건) + 최근순 조회 가속
CREATE INDEX IF NOT EXISTS idx_exhibit_inquiry_email_created ON exhibit_inquiry (email, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_exhibit_inquiry_created ON exhibit_inquiry (created_at DESC);
-- ── 최소 시드(멱등) — 관리 백오피스 목록 초기 표시용 데모 1건 ──────────────
INSERT INTO exhibit_inquiry
(id, receipt_no, inquiry_type, company, contact_name, email, phone, scale, hall_pref, message,
agree_privacy, agree_marketing, status, created_at)
VALUES
('inq-seed-0001', 'KTX-INQ-20260101-0001', 'shell', '(주)데모컴퍼니', '홍길동',
'demo@example.com', '010-0000-0000', '2-4개 부스 (18-36sqm)', 'Hall 1',
'스마트팩토리 전시 참가를 검토 중입니다. 부스 배치와 요금 안내 부탁드립니다.',
true, false, 'received', now() - interval '30 days')
ON CONFLICT (id) DO NOTHING;

View File

@ -81,10 +81,10 @@ export function AuditLogPage() {
{/* 필터 바 */}
<section className="kx-card kx-adm-filter" aria-label="감사 로그 필터">
<div className="kx-adm-filter__grid">
<label className="kx-field">
<span className="kx-field__label"> </span>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<input
className="kx-field__input"
className="kx-adm-field__input"
list="audit-actions"
placeholder="전체 액션"
value={action}
@ -96,19 +96,19 @@ export function AuditLogPage() {
))}
</datalist>
</label>
<label className="kx-field">
<span className="kx-field__label"> ID</span>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> ID</span>
<input
className="kx-field__input"
className="kx-adm-field__input"
placeholder="예: user-102"
value={actorId}
onChange={(e) => setActorId(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-field__label"> ID</span>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> ID</span>
<input
className="kx-field__input"
className="kx-adm-field__input"
placeholder="예: EVT-2026-01"
value={eventId}
onChange={(e) => setEventId(e.target.value)}

View File

@ -147,7 +147,7 @@ export function LoginSlideAdminPage() {
</div>
</div>
<div className="kx-slideadm__fields">
<div className="kx-field">
<div className="kx-adm-field">
<label htmlFor="slide-title"> </label>
<input
id="slide-title"
@ -159,7 +159,7 @@ export function LoginSlideAdminPage() {
required
/>
</div>
<div className="kx-field">
<div className="kx-adm-field">
<label htmlFor="slide-caption">( )</label>
<input
id="slide-caption"

View File

@ -1,64 +1,42 @@
/*
* SCR-A8 (M18). Stitch scr_a8 .
* 원천: 샘플( ). classpath (resources/rulesets/compliance-v1.json) ,
* / REST (RuleSetLoader ). , .
* v1.1 compliance-v1.json .
* 원천: API( ) GET /api/admin/rulesets( ) · GET /api/admin/rulesets/{name}( ).
* classpath (resources/rulesets/*.json) ( = ).
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { rulesetApi, type RulesetRule } from './adminModulesApi';
import './admin-modules.css';
interface RuleItem {
code: string;
label: string;
value: string;
logic: string;
severity: 'block' | 'warn';
}
interface RulesetVersion {
version: string;
effectiveDate: string;
status: 'current' | 'expired' | 'draft';
note?: string;
rules: RuleItem[];
}
// 실제 compliance-v1.json(rulesetVersion=compliance-v1.1, effectiveDate=2026-07-12) 반영 — 읽기 전용 샘플.
const V11_RULES: RuleItem[] = [
{ code: 'HEIGHT_MAX', label: '장치물 높이', value: '≤ 5m', logic: '최대 높이 제한', severity: 'block' },
{ code: 'RIGGING_RANGE', label: '리깅 높이', value: '6.58.5m', logic: '구조계산서 제출(D-7) 필요', severity: 'warn' },
{ code: 'MEZZANINE_RATIO', label: '복층 부스', value: '≤ 1/2 면적', logic: '전체 점유 면적 대비', severity: 'block' },
{ code: 'FIRE_RETARDANT', label: '방염 성능', value: 'A급 필수', logic: '전 자재 방염', severity: 'block' },
{ code: 'FLOOR_LOAD', label: '바닥 하중', value: '홀6 2 · 홀7~10 5 t/㎡', logic: '홀별 하중 초과 금지', severity: 'block' },
{ code: 'AISLE_WIDTH_MIN', label: '피난 통로 폭', value: '≥ 3m', logic: 'PostGIS 산출값 대조', severity: 'block' },
{ code: 'EXIT_ACCESS', label: '비상구 접근성', value: '차단 0', logic: '부스가 비상구 차단 금지', severity: 'block' },
{ code: 'BOOTH_OVERLAP', label: '부스 겹침', value: '겹침 0', logic: 'ST_Intersects 무결성', severity: 'block' },
{ code: 'CLEARANCE_WALL', label: '벽 이격', value: '≥ 0.3m', logic: '인접 벽 이격', severity: 'warn' },
{ code: 'CLEARANCE_CEILING', label: '천장 이격', value: '≥ 0.6m', logic: '천장 이격', severity: 'warn' },
{ code: 'NOISE_LIMIT', label: '소음 규정', value: '≤ 75dB', logic: '장내 상시 기준', severity: 'warn' },
{ code: 'PROHIBITED_WORK', label: '금지 작업', value: '전기톱·용접·페인트', logic: '장내 금지작업 미포함', severity: 'warn' },
{ code: 'LIGHTING_BRING_IN', label: '조명 반입', value: '지정 조명만', logic: '조명 반입 금지', severity: 'warn' },
];
const VERSIONS: RulesetVersion[] = [
{
version: 'compliance-v1.1',
effectiveDate: '2026-07-12',
status: 'current',
note: '최신 전시 규정 통합본 (PostGIS 배치 무결성 규칙 추가)',
rules: V11_RULES,
},
{
version: 'compliance-v1.0',
effectiveDate: '2026-01-01',
status: 'expired',
note: '초기 규정 룰셋',
rules: V11_RULES.slice(0, 4),
},
];
export function RulesetVersionsPage() {
const [selected, setSelected] = useState(VERSIONS[0].version);
const current = VERSIONS.find((v) => v.version === selected) ?? VERSIONS[0];
const listQ = useQuery({
queryKey: ['admin-rulesets'],
queryFn: () => rulesetApi.list(),
retry: false,
});
const versions = listQ.data ?? [];
const [selected, setSelected] = useState<string | null>(null);
// 목록 로드 시 최초 파일 자동 선택(사용자 선택이 우선).
useEffect(() => {
if (!selected && versions.length > 0) {
setSelected(versions[0].name);
}
}, [versions, selected]);
const activeName = selected ?? versions[0]?.name ?? null;
const current = versions.find((v) => v.name === activeName) ?? null;
const detailQ = useQuery({
queryKey: ['admin-ruleset', activeName],
queryFn: () => rulesetApi.detail(activeName as string),
enabled: !!activeName,
retry: false,
});
const rules: RulesetRule[] = detailQ.data?.rules ?? [];
return (
<div className="kx-page kx-adm">
@ -70,34 +48,43 @@ export function RulesetVersionsPage() {
</header>
<div className="kx-adm-sample-banner" role="status">
<span className="kx-bi__degraded"> · </span>
/ REST API가 (compliance-v1.json)
. · .
<span className="kx-bi__degraded"> </span>
(rulesets/*.json) . ·
.
</div>
{listQ.isLoading && <RulesetSkeleton />}
{listQ.isError && (
<ErrorState message="룰셋 목록을 불러오지 못했습니다." onRetry={() => listQ.refetch()} />
)}
{!listQ.isLoading && !listQ.isError && versions.length === 0 && (
<EmptyState title="배포된 룰셋이 없습니다" />
)}
{versions.length > 0 && (
<div className="kx-adm-ruleset">
{/* 좌: 버전 히스토리 */}
<aside className="kx-adm-ruleset__versions" aria-label="버전 히스토리">
<div className="kx-adm-ruleset__vhead">
<h2> </h2>
<button className="kx-btn kx-btn--secondary" disabled title="API 미제공">
</button>
</div>
<ul className="kx-adm-vlist">
{VERSIONS.map((v) => (
<li key={v.version}>
{versions.map((v) => (
<li key={v.name}>
<button
type="button"
className={`kx-adm-vitem ${v.version === selected ? 'is-active' : ''}`}
onClick={() => setSelected(v.version)}
className={`kx-adm-vitem ${v.name === activeName ? 'is-active' : ''}`}
onClick={() => setSelected(v.name)}
>
<span className="kx-adm-vitem__top">
<strong>{v.version}</strong>
<VersionBadge status={v.status} />
<span className="kx-adm-pill kx-adm-pill--neutral"> {v.ruleCount}</span>
</span>
<span className="kx-adm-vitem__date"> {v.effectiveDate}</span>
{v.note && <span className="kx-adm-vitem__note">{v.note}</span>}
<span className="kx-adm-vitem__date">
{v.effectiveDate ? `발효일 ${v.effectiveDate}` : v.name}
</span>
<ModuleDistribution dist={v.moduleDistribution} />
</button>
</li>
))}
@ -108,13 +95,30 @@ export function RulesetVersionsPage() {
<section className="kx-card kx-adm-ruleset__editor" aria-label="규칙 목록">
<div className="kx-adm-ruleset__ehead">
<div>
<h2>{current.version} ({current.rules.length})</h2>
<p className="kx-adm-muted"> {current.effectiveDate}</p>
<h2>
{current?.version ?? activeName} ({rules.length})
</h2>
<p className="kx-adm-muted">
{detailQ.data?.effectiveDate
? `발효일 ${detailQ.data.effectiveDate}`
: current?.name}
</p>
</div>
<button className="kx-btn kx-btn--secondary" disabled title="API 미제공">
(diff)
</button>
</div>
{detailQ.isLoading && <Skeleton height={240} radius={12} />}
{detailQ.isError && (
<ErrorState message="규칙 전문을 불러오지 못했습니다." onRetry={() => detailQ.refetch()} />
)}
{!detailQ.isLoading && !detailQ.isError && rules.length === 0 && (
<EmptyState
title="규칙 배열이 없는 룰셋입니다"
description="요율 마스터 등 규칙(rules) 없이 요율·단가만 정의된 룰셋입니다."
/>
)}
{rules.length > 0 && (
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-rules">
<thead>
@ -126,16 +130,18 @@ export function RulesetVersionsPage() {
</tr>
</thead>
<tbody>
{current.rules.map((r) => (
{rules.map((r) => (
<tr key={r.code}>
<td>
<span className="kx-adm-rulename">{r.label}</span>
<span className="kx-adm-rulename">{r.label ?? r.code}</span>
<span className="kx-adm-rulecode">{r.code}</span>
</td>
<td className="kx-adm-mono">{r.value}</td>
<td className="kx-adm-muted">{r.logic}</td>
<td className="kx-adm-mono">{formatValue(r)}</td>
<td className="kx-adm-muted">{formatLogic(r)}</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}>
<span
className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}
>
{r.severity === 'block' ? '차단' : '경고'}
</span>
</td>
@ -144,21 +150,73 @@ export function RulesetVersionsPage() {
</tbody>
</table>
</div>
<p className="kx-adm-disclaimer">
.
</p>
)}
{detailQ.data?.disclaimer && (
<p className="kx-adm-disclaimer">{detailQ.data.disclaimer}</p>
)}
</section>
</div>
)}
</div>
);
}
function VersionBadge({ status }: { status: RulesetVersion['status'] }) {
const map = {
current: { cls: 'success', label: '현재 발효' },
expired: { cls: 'neutral', label: '만료' },
draft: { cls: 'warn', label: '초안' },
} as const;
const m = map[status];
return <span className={`kx-adm-pill kx-adm-pill--${m.cls}`}>{m.label}</span>;
function ModuleDistribution({ dist }: { dist: Record<string, number> }) {
const entries = Object.entries(dist);
if (entries.length === 0) return null;
return (
<span className="kx-adm-vitem__note">
{entries.map(([m, n]) => `${m} ${n}`).join(' · ')}
</span>
);
}
/** 원문 규칙에서 표시용 수치/설정값을 유도한다(operator + threshold/min/max/unit). */
function formatValue(r: RulesetRule): string {
const unit = r.unit ?? '';
switch (r.operator) {
case 'lte':
return `${fmtNum(r.threshold)}${unit}`;
case 'gte':
return `${fmtNum(r.threshold)}${unit}`;
case 'eq':
return `= ${fmtNum(r.threshold)}${unit}`;
case 'between':
return `${fmtNum(r.min)}${fmtNum(r.max)}${unit}`;
case 'isTrue':
return '필수';
case 'excludesAll':
return (r.forbidden ?? []).join(' · ');
case 'lteHall':
return r.hallLimits
? Object.entries(r.hallLimits)
.map(([h, v]) => `${h} ${v}`)
.join(' · ') + unit
: `홀별 ${unit}`;
default:
return r.threshold != null ? `${fmtNum(r.threshold)}${unit}` : '—';
}
}
/** 원문 규칙에서 논리/요구사항 설명을 유도한다(metric·requiresDocument·note). */
function formatLogic(r: RulesetRule): string {
const parts: string[] = [];
if (r.metric) parts.push(r.metric);
if (r.requiresDocument) parts.push(`문서 필요: ${r.requiresDocument}`);
if (r.note) parts.push(r.note);
return parts.length > 0 ? parts.join(' · ') : (r.group ?? '—');
}
function fmtNum(n: number | null | undefined): string {
return n == null ? '—' : String(n);
}
function RulesetSkeleton() {
return (
<div aria-hidden="true" style={{ display: 'grid', gap: 16 }}>
<Skeleton height={48} radius={12} />
<Skeleton height={320} radius={12} />
</div>
);
}

View File

@ -186,7 +186,7 @@ function SettingRowItem({
) : (
<>
<input
className="kx-field__input kx-adm-setrow__input"
className="kx-adm-field__input kx-adm-setrow__input"
type={isSecret ? 'password' : 'text'}
value={val}
placeholder={isSecret ? '설정됨(마스킹) — 변경 시 새 값 입력' : ''}
@ -259,26 +259,26 @@ function AddSettingCard({
<section className="kx-card kx-adm-add" aria-label="설정 추가">
<h2 className="kx-adm-add__title"> / </h2>
<div className="kx-adm-add__grid">
<label className="kx-field">
<span className="kx-field__label"> </span>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<input
className="kx-field__input"
className="kx-adm-field__input"
placeholder="예: deadline.utility.days"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-field__label"></span>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<input
className="kx-field__input"
className="kx-adm-field__input"
type={secret ? 'password' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-field__label"></span>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<select className="kx-select" value={type} onChange={(e) => setType(e.target.value)}>
{VALUE_TYPES.map((t) => (
<option key={t} value={t}>
@ -287,10 +287,10 @@ function AddSettingCard({
))}
</select>
</label>
<label className="kx-field kx-adm-add__desc">
<span className="kx-field__label"></span>
<label className="kx-adm-field kx-adm-add__desc">
<span className="kx-adm-field__label"></span>
<input
className="kx-field__input"
className="kx-adm-field__input"
value={desc}
onChange={(e) => setDesc(e.target.value)}
/>

View File

@ -4,6 +4,7 @@
* IA .
*/
import { useState } from 'react';
import { IconCheck } from '../../components/ui/icons';
import './admin-modules.css';
interface TenantRow {
@ -102,7 +103,7 @@ export function TenantAdminPage() {
return (
<li key={s.key} className={`kx-adm-wiz__step is-${state}`}>
<span className="kx-adm-wiz__dot" aria-hidden="true">
{state === 'done' ? '✓' : i + 1}
{state === 'done' ? <IconCheck size={14} /> : i + 1}
</span>
<span className="kx-adm-wiz__body">
<span className="kx-adm-wiz__label">{s.label}</span>

View File

@ -25,19 +25,19 @@
max-width: 720px;
}
/* ── 폼 필드 프리미티브 ── */
.kx-field {
/* ── 폼 필드 프리미티브(관리자 모듈 격리 — .kx-field 전역 충돌 방지) ── */
.kx-adm-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.kx-field__label {
.kx-adm-field__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
}
.kx-field__input {
.kx-adm-field__input {
height: 36px;
padding: 0 var(--space-3);
border: var(--border-card);
@ -47,7 +47,7 @@
color: var(--color-neutral-900);
width: 100%;
}
.kx-field__input:focus {
.kx-adm-field__input:focus {
outline: 2px solid var(--color-primary-100);
border-color: var(--color-primary-600);
}

View File

@ -98,3 +98,50 @@ export const settingApi = {
save: (body: SettingSaveBody) => api.post<void>('/api/admin/settings', body),
remove: (key: string) => api.del<void>(`/api/admin/settings/${encodeURIComponent(key)}`),
};
// ── SCR-A8 규정 룰셋 버전 조회(읽기 전용) ──
/**
* . GET /api/admin/rulesets (AdminRulesetController) .
* classpath API는 ( = ).
*/
export interface RulesetSummary {
name: string; // 파일명 (예: compliance-v1.json)
version: string;
effectiveDate: string | null;
ruleCount: number;
moduleDistribution: Record<string, number>;
}
/** 규정 룰셋 규칙 1건(compliance-*.json rules[] 항목). 요율 마스터에는 rules 가 없다. */
export interface RulesetRule {
code: string;
group?: string | null;
label?: string | null;
module?: string[] | null;
severity?: 'block' | 'warn' | string | null;
metric?: string | null;
operator?: string | null;
threshold?: number | null;
min?: number | null;
max?: number | null;
unit?: string | null;
requiresDocument?: string | null;
hallLimits?: Record<string, number> | null;
forbidden?: string[] | null;
note?: string | null;
}
/** 룰셋 규칙 전문(JSON 원문). 규정 파일은 rules[], 요율 파일은 rental/utility 등. */
export interface RulesetDetail {
rulesetVersion?: string | null;
effectiveDate?: string | null;
disclaimer?: string | null;
source?: string | null;
rules?: RulesetRule[] | null;
}
export const rulesetApi = {
list: () => api.get<RulesetSummary[]>('/api/admin/rulesets'),
detail: (name: string) =>
api.get<RulesetDetail>(`/api/admin/rulesets/${encodeURIComponent(name)}`),
};

View File

@ -0,0 +1,219 @@
/*
* M15 / API (SCR-26·27·29·38).
* (../../api/client) client.ts·endpoints.ts·types.ts .
* 정본: 백엔드 com.zioinfo.kintex.auction (AuctionController·ContractorController) .
*
* :
* - (GET /{id}) · (ranking priceMasked=true·price=null).
* - (award-view) 200, 403.
* ( ).
*/
import { api } from '../../api/client';
export type AuctionType = '역경매' | 'RFQ';
export type AuctionStatus = '진행중' | '마감' | '정산중';
export type AwardCriteria = 'lowest' | 'comprehensive';
export type MaterialKind = 'layout' | 'design' | 'boq' | 'aiimage';
export interface AuctionSummary {
id: string;
eventId: string;
title: string;
category: string;
type: AuctionType;
status: AuctionStatus;
round: number;
dday: number;
bidderCount: number;
lowestPrice: number | null;
awardedCompany: string | null;
finalPrice: number | null;
materials: MaterialKind[];
accent: string; // primary | secondary | tertiary
}
export interface Weights {
price: number;
reputation: number;
delivery: number;
}
export interface MaterialPackage {
layoutUrl: string | null;
designUrl: string | null;
boqUrl: string | null;
aiImageUrl: string | null;
}
/** 봉인 순위 행 — priceMasked=true 이면 price 는 null(타사 비공개). */
export interface RankRow {
rank: number;
alias: string;
isMe: boolean;
isLowest: boolean;
priceMasked: boolean;
price: number | null;
}
export interface AuctionDetail {
id: string;
eventId: string;
title: string;
category: string;
type: AuctionType;
status: AuctionStatus;
round: number;
dday: number;
deadline: string;
bidderCount: number;
lowestPrice: number | null;
awardCriteria: AwardCriteria;
weights: Weights;
materials: MaterialKind[];
materialPackage: MaterialPackage;
sealed: boolean;
myRank: number | null;
myPrice: number | null;
ranking: RankRow[];
canBid: boolean;
canViewAward: boolean;
awardedCompany: string | null;
finalPrice: number | null;
}
export interface BidResult {
id: string;
round: number;
subtotal: number;
vat: number;
total: number;
version: number;
myRank: number | null;
lowestPrice: number | null;
}
export interface Quote {
bidId: string;
companyId: string;
alias: string;
subtitle: string;
recommended: boolean;
totalPrice: number;
priceDeltaPct: number;
isLowestPrice: boolean;
leadDays: number;
isShortestLead: boolean;
reputation: number;
score: number;
}
export interface RiskBar {
label: string;
pct: number;
tone: 'success' | 'ai' | 'warn';
}
export interface AwardResult {
id: string;
bidId: string;
companyName: string | null;
awardedAt: string | null;
}
export interface AwardView {
auctionId: string;
auctionTitle: string;
bidderCount: number;
awardCriteria: AwardCriteria;
weights: Weights;
quotes: Quote[];
riskBars: RiskBar[];
awarded: AwardResult | null;
}
// ── 업체 포털 대시보드(SCR-38) ──
export interface Kpi {
label: string;
value: number;
tone: 'normal' | 'success' | 'error';
}
export interface AwardedBooth {
auctionId: string;
event: string;
name: string;
booth: string;
client: string | null;
dday: number | null;
stage: string;
}
export interface MyBid {
auctionId: string;
auctionTitle: string;
myPrice: number;
myRank: number | null;
dday: number;
}
export interface FeedItem {
text: string;
meta: string;
tone: 'ok' | 'warn' | 'info';
}
export interface ContractorDashboard {
companyName: string | null;
kpis: Kpi[];
awardedBooths: AwardedBooth[];
myAuctionBids: MyBid[];
feed: FeedItem[];
}
export interface CreateAuctionBody {
eventId: string;
title: string;
category: string;
type: AuctionType;
awardCriteria: AwardCriteria;
weights: Weights;
round: number;
deadline: string;
materialPackageId?: string | null;
materials: MaterialKind[];
invitedCompanyIds: string[];
}
export interface BidBody {
total: number;
leadDays?: number;
validUntil?: string | null;
terms?: string | null;
}
export interface AwardBody {
bidId: string;
reason: string;
}
export const MATERIAL_LABEL: Record<MaterialKind, string> = {
layout: '배치',
design: '설계',
boq: '물량서',
aiimage: '예상이미지',
};
/** 원 금액 → ₩ 구분자 포맷. */
export function formatWon(v: number): string {
return `${v.toLocaleString('ko-KR')}`;
}
export const auctionApi = {
list: (eventId?: string) =>
api.get<AuctionSummary[]>(`/api/auctions${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`),
detail: (id: string) => api.get<AuctionDetail>(`/api/auctions/${encodeURIComponent(id)}`),
create: (body: CreateAuctionBody) => api.post<AuctionSummary>('/api/auctions', body),
bid: (id: string, body: BidBody) => api.post<BidResult>(`/api/auctions/${encodeURIComponent(id)}/bids`, body),
award: (id: string, body: AwardBody) => api.post<AwardResult>(`/api/auctions/${encodeURIComponent(id)}/award`, body),
awardView: (id: string) => api.get<AwardView>(`/api/auctions/${encodeURIComponent(id)}/award-view`),
};
export const contractorApi = {
dashboard: () => api.get<ContractorDashboard>('/api/contractor/dashboard'),
};

View File

@ -1,11 +1,13 @@
/*
* SCR-35 CMS · [M17]. 참조: design.md §3 SCR-35.
* : 콘텐츠 /( · ) · : 블록 (·· ) · : 게시 (·· ).
* M17 ("샘플 데이터" ). API .
* : 콘텐츠 ( ) · : 에디터(··) · : 게시 ( draftreviewapprovedpublished·· ).
* API 전환: GET/POST /api/cms/contents · PATCH /api/cms/contents/{id}/status?value=. ( 400 ).
*/
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { ApiRequestError } from '../../api/client';
import { cmsApi, type CmsContent, type FlowStatus } from './cmsApi';
import {
IconDocument,
IconSpark,
@ -17,15 +19,6 @@ import {
} from '../../components/ui/icons';
import './cms.css';
type Lang = 'ko' | 'en' | 'zh' | 'ja';
const LANGS: { key: Lang; label: string }[] = [
{ key: 'ko', label: '한국어' },
{ key: 'en', label: 'English' },
{ key: 'zh', label: '中文' },
{ key: 'ja', label: '日本語' },
];
type FlowStatus = 'draft' | 'review' | 'approved' | 'published';
const STATUS_META: Record<FlowStatus, { label: string; cls: string }> = {
draft: { label: '초안', cls: 'kx-cms-pill--draft' },
review: { label: '검수 중', cls: 'kx-cms-pill--review' },
@ -34,19 +27,6 @@ const STATUS_META: Record<FlowStatus, { label: string; cls: string }> = {
};
const FLOW_ORDER: FlowStatus[] = ['draft', 'review', 'approved', 'published'];
interface ContentItem {
id: string;
name: string;
status: FlowStatus;
}
const SAMPLE_CONTENT: ContentItem[] = [
{ id: 'page-home', name: '페이지', status: 'published' },
{ id: 'notice', name: '공지사항', status: 'draft' },
{ id: 'about', name: '행사 소개', status: 'review' },
{ id: 'speakers', name: '연사 정보', status: 'published' },
{ id: 'faq', name: 'FAQ', status: 'published' },
];
// 툴바용 소형 인라인 아이콘(stroke, currentColor) — 라이브러리 미보유분.
function ToolIcon({ d }: { d: string }) {
return (
@ -62,72 +42,120 @@ const TOOLS = [
{ title: '목록', d: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
];
function fmtDate(iso: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' });
}
export function CmsWorkflowPage() {
const [lang, setLang] = useState<Lang>('ko');
const [selectedId, setSelectedId] = useState('about');
const [content, setContent] = useState<ContentItem[]>(SAMPLE_CONTENT);
const [items, setItems] = useState<CmsContent[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [signage, setSignage] = useState(true);
const [mailing, setMailing] = useState(false);
const selected = useMemo(
() => content.find((c) => c.id === selectedId) ?? content[0],
[content, selectedId],
);
function advance(next: FlowStatus) {
setContent((prev) => prev.map((c) => (c.id === selected.id ? { ...c, status: next } : c)));
async function reload(keepSelection = true) {
setLoading(true);
setError(null);
try {
const res = await cmsApi.listContents({ size: 100 });
setItems(res.items);
if (!keepSelection || !res.items.some((c) => c.id === selectedId)) {
setSelectedId(res.items[0]?.id ?? null);
}
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '콘텐츠를 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}
const curIdx = FLOW_ORDER.indexOf(selected.status);
useEffect(() => {
void reload(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selected = useMemo(
() => items.find((c) => c.id === selectedId) ?? null,
[items, selectedId],
);
async function advance(next: FlowStatus) {
if (!selected) return;
setBusy(true);
setError(null);
try {
const updated = await cmsApi.transition(selected.id, next);
setItems((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '상태 전이에 실패했습니다.');
} finally {
setBusy(false);
}
}
async function createDraft() {
setBusy(true);
setError(null);
try {
const created = await cmsApi.createContent({ title: '새 콘텐츠 (초안)', contentType: 'PAGE' });
setItems((prev) => [created, ...prev]);
setSelectedId(created.id);
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '콘텐츠 생성에 실패했습니다.');
} finally {
setBusy(false);
}
}
const curIdx = selected ? FLOW_ORDER.indexOf(selected.status) : -1;
return (
<div className="kx-page">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title">
(CMS)
<span className="kx-sample-badge">
<IconWarning size={12} />
</span>
</h1>
<h1 className="kx-cms__title"> (CMS)</h1>
<p className="kx-cms__subtitle">
· ( ) · ·
</p>
</div>
<div className="kx-cms__head-actions">
<Button variant="secondary"> </Button>
<Button variant="secondary" onClick={createDraft} disabled={busy} leadingIcon={<IconPlus size={16} />}>
</Button>
</div>
</header>
{error && (
<div className="kx-cms-alert" role="alert">
<IconWarning size={14} /> {error}
</div>
)}
<div className="kx-cms-grid">
{/* 좌: 콘텐츠 목록 */}
<section className="kx-card kx-cms-col" aria-label="콘텐츠 목록">
<div className="kx-seg" role="tablist" aria-label="언어">
{LANGS.map((l) => (
<button
key={l.key}
role="tab"
aria-selected={lang === l.key}
className={`kx-seg__btn ${lang === l.key ? 'is-active' : ''}`}
onClick={() => setLang(l.key)}
>
{l.label}
</button>
))}
</div>
{loading ? (
<div className="kx-cms-empty"> </div>
) : items.length === 0 ? (
<div className="kx-cms-empty"> . .</div>
) : (
<ul className="kx-cms-list">
{content.map((c) => {
{items.map((c) => {
const m = STATUS_META[c.status];
return (
<li key={c.id}>
<button
className={`kx-cms-list__item ${c.id === selected.id ? 'is-active' : ''}`}
className={`kx-cms-list__item ${c.id === selectedId ? 'is-active' : ''}`}
onClick={() => setSelectedId(c.id)}
aria-current={c.id === selected.id}
aria-current={c.id === selectedId}
>
<span className="kx-cms-list__label">
<IconDocument size={18} />
<span className="kx-cms-list__name">{c.name}</span>
<span className="kx-cms-list__name">{c.title}</span>
</span>
<span className={`kx-cms-pill ${m.cls}`}>{m.label}</span>
</button>
@ -135,15 +163,17 @@ export function CmsWorkflowPage() {
);
})}
</ul>
)}
</section>
{/* 중: 에디터 */}
<section className="kx-card kx-cms-col kx-cms-editor" aria-label="콘텐츠 에디터">
<input
className="kx-cms-title-input"
defaultValue={selected.name}
defaultValue={selected?.title ?? ''}
aria-label="제목"
key={selected.id}
key={selected?.id ?? 'none'}
placeholder="콘텐츠 제목"
/>
<div className="kx-cms-toolbar" role="toolbar" aria-label="서식">
@ -179,42 +209,34 @@ export function CmsWorkflowPage() {
</div>
<div className="kx-cms-blocks">
<div className="kx-cms-block">
<div className="kx-cms-block__img"> </div>
</div>
<div className="kx-cms-block">
<div className="kx-cms-block__head">
<span className="kx-cms-block__kind"> </span>
<span className="kx-cms-block__kind"></span>
</div>
<p className="kx-cms-block__body">
KINTEX .
AI , ,
.
{selected?.body || '본문 블록을 편집하세요. 텍스트·이미지·버튼·AI 요약 블록을 조합할 수 있습니다.'}
</p>
</div>
<div className="kx-cms-block kx-cms-block--ai">
<div className="kx-cms-block__head">
<AiLabel>AI </AiLabel>
<span className="kx-cms-disabled-hint" title="AI 요약 자동 생성은 아직 배선되지 않았습니다(AiTextRouter 연동 예정).">
</span>
</div>
<p className="kx-cms-block__body">
AI와
.
AI AiTextRouter(Claude) .
</p>
</div>
</div>
<div>
<div className="kx-cms-sublabel" style={{ marginBottom: 8 }}>
</div>
<div className="kx-cms-sched">
<IconCalendar size={18} />
<span>2026.08.01</span>
<span aria-hidden="true">·</span>
<span>09:00</span>
<Button variant="ghost" style={{ marginLeft: 'auto' }}>
</Button>
<span>{selected?.scheduledAt ? fmtDate(selected.scheduledAt) : '예약 없음'}</span>
</div>
</div>
</section>
@ -237,53 +259,36 @@ export function CmsWorkflowPage() {
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
{selected.status === 'draft' && (
<Button block variant="secondary" onClick={() => advance('review')}>
{selected?.status === 'draft' && (
<Button block variant="secondary" onClick={() => advance('review')} disabled={busy}>
</Button>
)}
{selected.status === 'review' && (
<Button block onClick={() => advance('approved')} leadingIcon={<IconCheckCircle size={16} />}>
{selected?.status === 'review' && (
<Button block onClick={() => advance('approved')} disabled={busy} leadingIcon={<IconCheckCircle size={16} />}>
</Button>
)}
{selected.status === 'approved' && (
<Button block onClick={() => advance('published')} leadingIcon={<IconSpark size={16} />}>
{selected?.status === 'approved' && (
<Button block onClick={() => advance('published')} disabled={busy} leadingIcon={<IconSpark size={16} />}>
</Button>
)}
{selected.status === 'published' && (
<Button block variant="secondary" onClick={() => advance('draft')}>
</Button>
{selected?.status === 'published' && (
<div className="kx-cms-terminal">
<IconCheckCircle size={14} />
</div>
)}
</div>
</div>
<div className="kx-cms-flow__section">
<span className="kx-cms-flow__label"> </span>
<div className="kx-cms-ver">
<div className="kx-cms-ver__item kx-cms-ver__item--current">
<div className="kx-cms-ver__v">v3 ( )</div>
<div className="kx-cms-ver__meta">2026.07.25 · </div>
</div>
<div className="kx-cms-ver__item kx-cms-ver__item--published">
<div className="kx-cms-ver__v">v2 ( )</div>
<div className="kx-cms-ver__meta">2026.06.10 · </div>
</div>
<div className="kx-cms-ver__item">
<div className="kx-cms-ver__v">v1</div>
<div className="kx-cms-ver__meta">2026.05.15 · </div>
</div>
</div>
</div>
<div className="kx-cms-schedcard">
<div className="kx-cms-schedcard__t">
<IconCalendar size={14} />
<IconCalendar size={14} />
</div>
<div style={{ fontSize: 13, color: 'var(--color-neutral-700)' }}>
2026.08.01 09:00
{fmtDate(selected?.updatedAt ?? null)}
{selected?.authorName ? ` · ${selected.authorName}` : ''}
</div>
</div>

View File

@ -1,11 +1,14 @@
/*
* SCR-36 [M17]. 참조: design.md §3 SCR-36.
* : 섹션 ( ·· ·)· · : 라이브 (/ , SCR-P5 ) · : 게시 ·URL·SEO··.
* M17 ("샘플 데이터" ). AiLabel . API .
* : 섹션 (/)· · : 라이브 (/) · : 게시 ·URL·SEO··/.
* API 전환: GET/PUT /api/exhibitors/{exhibitorId}/microsite. ··SEO· ( = /api/public/microsites/{id}).
* AiLabel ( ). = 'hanbit'(V19 ).
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge';
import { ApiRequestError } from '../../api/client';
import { cmsApi, type Microsite, type MicrositeSection } from './cmsApi';
import {
IconImage,
IconPlus,
@ -24,18 +27,6 @@ const LANGS: { key: Lang; label: string }[] = [
{ key: 'ja', label: '일' },
];
interface Section {
id: string;
name: string;
visible: boolean;
}
const SAMPLE_SECTIONS: Section[] = [
{ id: 'intro', name: '부스 소개', visible: true },
{ id: 'products', name: '제품', visible: true },
{ id: 'gallery', name: '예상샷 갤러리', visible: true },
{ id: 'contact', name: '연락처', visible: true },
];
const THEMES = [
{ key: 'blue', color: 'var(--color-primary-600)' },
{ key: 'violet', color: 'var(--color-ai-accent)' },
@ -43,7 +34,9 @@ const THEMES = [
{ key: 'red', color: 'var(--color-error)' },
];
// 우측/좌측 소형 인라인 아이콘.
// 데모 참가업체(라우트 param 미배선 트랙 — V19 시드 'hanbit').
const DEMO_EXHIBITOR_ID = 'hanbit';
function MiniIcon({ d, size = 16 }: { d: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@ -53,44 +46,118 @@ function MiniIcon({ d, size = 16 }: { d: string; size?: number }) {
}
const D_DRAG = 'M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01';
const D_EYE = 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z M12 12h.01';
const D_EDIT = 'M11 4H4v16h16v-7M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z';
const D_COPY = 'M9 9h10v10H9z M5 15H4V4h11v1';
const SECTION_NAMES: Record<string, string> = {
intro: '부스 소개',
products: '제품',
gallery: '예상샷 갤러리',
contact: '연락처',
meeting: '미팅 예약',
};
export function MicrositeBuilderPage() {
const [sections, setSections] = useState<Section[]>(SAMPLE_SECTIONS);
const [activeSection, setActiveSection] = useState('intro');
const [site, setSite] = useState<Microsite | null>(null);
const [sections, setSections] = useState<MicrositeSection[]>([]);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [device, setDevice] = useState<'desktop' | 'mobile'>('desktop');
const [theme, setTheme] = useState('blue');
const [lang, setLang] = useState<Lang>('ko');
const [title, setTitle] = useState('한빛로보틱스 | KINTEX AI 전시');
const [meta, setMeta] = useState(
'KINTEX AI EXPO (주)한빛로보틱스 공식 마이크로사이트 — 최첨단 협동로봇·서비스 로봇 솔루션을 만나보세요.',
);
const [title, setTitle] = useState('');
const [meta, setMeta] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
(async () => {
setLoading(true);
setError(null);
try {
const m = await cmsApi.getMicrosite(DEMO_EXHIBITOR_ID);
applySite(m);
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '마이크로사이트를 불러오지 못했습니다.');
} finally {
setLoading(false);
}
})();
}, []);
function applySite(m: Microsite) {
setSite(m);
const secs = Array.isArray(m.sections) ? m.sections : [];
setSections(secs);
setActiveSection(secs[0]?.id ?? null);
setTheme(m.theme || 'blue');
setTitle(m.seoTitle ?? '');
setMeta(m.seoMeta ?? '');
}
async function persist(status: 'draft' | 'published') {
if (!site) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const saved = await cmsApi.saveMicrosite(DEMO_EXHIBITOR_ID, {
eventId: site.eventId,
slug: site.slug,
exhibitorName: site.exhibitorName,
theme,
sections,
seoTitle: title,
seoMeta: meta,
langs: site.langs,
status,
});
applySite(saved);
setNotice(status === 'published' ? '마이크로사이트를 배포했습니다.' : '변경 사항을 저장했습니다.');
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '저장에 실패했습니다.');
} finally {
setBusy(false);
}
}
const brand = site?.exhibitorName ?? '(주)한빛로보틱스';
return (
<div className="kx-page">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title">
<span className="kx-sample-badge">
<IconWarning size={12} />
</span>
</h1>
<p className="kx-cms__subtitle">() · (··) </p>
<h1 className="kx-cms__title"> </h1>
<p className="kx-cms__subtitle">{brand} · (··) </p>
</div>
<div className="kx-cms__head-actions">
<Button variant="secondary" leadingIcon={<IconExpand size={16} />}>
<Button variant="secondary" leadingIcon={<IconExpand size={16} />} onClick={() => persist('draft')} disabled={busy || loading}>
</Button>
<Button onClick={() => persist('published')} disabled={busy || loading}>
</Button>
<Button></Button>
</div>
</header>
{error && (
<div className="kx-cms-alert" role="alert">
<IconWarning size={14} /> {error}
</div>
)}
{notice && (
<div className="kx-cms-alert kx-cms-alert--ok" role="status">
{notice}
</div>
)}
<div className="kx-mb-grid">
{/* 좌: 섹션 편집 + 테마 */}
<section className="kx-card kx-cms-col kx-mb-sections" aria-label="섹션 편집">
<div className="kx-cms-sublabel"> </div>
{loading ? (
<div className="kx-cms-empty"> </div>
) : (
<ul className="kx-mb-sec">
{sections.map((s) => (
<li key={s.id}>
@ -101,33 +168,47 @@ export function MicrositeBuilderPage() {
>
<span className="kx-mb-sec__label">
<MiniIcon d={D_DRAG} />
{s.name}
{SECTION_NAMES[s.type] ?? s.type}
</span>
<span className="kx-mb-sec__ctrls">
<span
className="kx-icon-btn"
title={s.visible ? '표시 중' : '숨김'}
className={`kx-icon-btn ${s.visible ? '' : 'is-off'}`}
title={s.visible ? '표시 중 (클릭하여 숨김)' : '숨김 (클릭하여 표시)'}
role="button"
tabIndex={0}
aria-pressed={s.visible}
onClick={(e) => {
e.stopPropagation();
setSections((prev) =>
prev.map((x) => (x.id === s.id ? { ...x, visible: !x.visible } : x)),
);
}}
onKeyDown={() => {}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSections((prev) =>
prev.map((x) => (x.id === s.id ? { ...x, visible: !x.visible } : x)),
);
}
}}
>
<MiniIcon d={D_EYE} />
</span>
<span className="kx-icon-btn" title="편집" aria-hidden="true">
<MiniIcon d={D_EDIT} />
</span>
</span>
</button>
</li>
))}
</ul>
<button className="kx-mb-addbtn">
)}
<button
className="kx-mb-addbtn"
onClick={() =>
setSections((prev) => [
...prev,
{ id: `sec-${Date.now()}`, type: 'intro', visible: true, order: prev.length, payload: {} },
])
}
>
<IconPlus size={16} />
</button>
@ -150,14 +231,6 @@ export function MicrositeBuilderPage() {
))}
</div>
</div>
<label className="kx-cms-field">
<span className="kx-cms-label"> </span>
<select className="kx-select" defaultValue="Pretendard">
<option>Pretendard (Corporate)</option>
<option>Inter (Modern)</option>
<option>Noto Sans KR</option>
</select>
</label>
</section>
{/* 중: 캔버스 프리뷰 */}
@ -183,28 +256,28 @@ export function MicrositeBuilderPage() {
</div>
<div className={`kx-mb-frame ${device === 'mobile' ? 'kx-mb-frame--mobile' : ''}`}>
{/* 마이크로사이트 nav */}
<div className="kx-mb-site__nav">
<div className="kx-mb-site__brand">
<span className="kx-mb-site__logo">H</span>
()
<span className="kx-mb-site__logo">{brand.replace(/[^가-힣A-Za-z]/g, '').charAt(0) || 'H'}</span>
{brand}
</div>
<nav className="kx-mb-site__menu">
<span></span>
<span></span>
<span></span>
<span style={{ color: 'var(--color-primary-700)' }}></span>
{sections.filter((s) => s.visible).map((s) => (
<span key={s.id}>{SECTION_NAMES[s.type] ?? s.type}</span>
))}
</nav>
</div>
{/* Hero */}
<section className="kx-mb-hero">
<div>
<span className="kx-mb-hero__eyebrow">KINTEX AI EXPO</span>
<h2 className="kx-mb-hero__title"> </h2>
<h2 className="kx-mb-hero__title">
{(sections.find((s) => s.type === 'intro')?.payload?.headline as string) ??
'지능형 로보틱스의 새로운 지평을 열다'}
</h2>
<p className="kx-mb-hero__desc">
,
.
{(sections.find((s) => s.type === 'intro')?.payload?.desc as string) ??
'산업용 로봇부터 서비스 자동화 솔루션까지, 미래 모빌리티 생태계를 경험하세요.'}
</p>
<div className="kx-mb-hero__cta">
<span className="kx-mb-btn kx-mb-btn--solid"> </span>
@ -213,7 +286,7 @@ export function MicrositeBuilderPage() {
</div>
</section>
{/* 제품 그리드 */}
{sections.some((s) => s.visible && s.type === 'products') && (
<section className="kx-mb-section kx-mb-section--alt">
<div className="kx-mb-section__head">
<span className="kx-mb-section__bar" />
@ -227,9 +300,7 @@ export function MicrositeBuilderPage() {
</div>
<div className="kx-mb-card__body">
<div className="kx-mb-card__title">HR-A1 </div>
<p className="kx-mb-card__desc">
·AI .
</p>
<p className="kx-mb-card__desc"> ·AI .</p>
</div>
</article>
<article className="kx-mb-card">
@ -239,15 +310,14 @@ export function MicrositeBuilderPage() {
</div>
<div className="kx-mb-card__body">
<div className="kx-mb-card__title">ServiBot Elite</div>
<p className="kx-mb-card__desc">
· · .
</p>
<p className="kx-mb-card__desc">· .</p>
</div>
</article>
</div>
</section>
)}
{/* AI 예상샷 갤러리 */}
{sections.some((s) => s.visible && s.type === 'gallery') && (
<section className="kx-mb-section">
<div className="kx-mb-section__head">
<span className="kx-mb-section__bar" style={{ background: 'var(--color-ai-accent)' }} />
@ -268,6 +338,7 @@ export function MicrositeBuilderPage() {
</div>
</div>
</section>
)}
</div>
</div>
</section>
@ -277,14 +348,15 @@ export function MicrositeBuilderPage() {
<div className="kx-mb-side__section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="kx-cms-sublabel"> </span>
<span className="kx-mb-status">
<span className="kx-mb-status__dot" />
<span className={`kx-mb-status ${site?.status === 'published' ? '' : 'kx-mb-status--draft'}`}>
<span className="kx-mb-status__dot" /> {site?.status === 'published' ? '게시됨' : '초안'}
</span>
</div>
<span className="kx-cms-label"> URL</span>
<span className="kx-cms-label"> URL</span>
<div className="kx-mb-url">
<span>hanbit.expo.kintex.kr</span>
<button className="kx-icon-btn" title="URL 복사" aria-label="URL 복사">
<span>{site?.slug ? `${site.slug}.expo.kintex.kr` : '슬러그 미설정'}</span>
<button className="kx-icon-btn" title="URL 복사" aria-label="URL 복사"
onClick={() => site?.slug && void navigator.clipboard?.writeText(`${site.slug}.expo.kintex.kr`)}>
<MiniIcon d={D_COPY} />
</button>
</div>
@ -294,19 +366,11 @@ export function MicrositeBuilderPage() {
<span className="kx-cms-sublabel">SEO · </span>
<label className="kx-cms-field">
<span className="kx-cms-label"> </span>
<input
className="kx-cms-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input className="kx-cms-input" value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="kx-cms-field">
<span className="kx-cms-label"> </span>
<textarea
className="kx-cms-textarea"
value={meta}
onChange={(e) => setMeta(e.target.value)}
/>
<textarea className="kx-cms-textarea" value={meta} onChange={(e) => setMeta(e.target.value)} />
</label>
</div>
@ -327,29 +391,8 @@ export function MicrositeBuilderPage() {
</div>
</div>
<div className="kx-mb-side__section">
<span className="kx-cms-sublabel"> </span>
<div className="kx-mb-activity">
<div className="kx-mb-activity__row">
<span className="kx-mb-activity__dot" />
<div>
<div className="kx-mb-activity__t"> </div>
<div className="kx-mb-activity__m">10 · </div>
</div>
</div>
<div className="kx-mb-activity__row">
<span className="kx-mb-activity__dot kx-mb-activity__dot--muted" />
<div>
<div className="kx-mb-activity__t" style={{ fontWeight: 400 }}>
SEO
</div>
<div className="kx-mb-activity__m">2 · </div>
</div>
</div>
</div>
</div>
<Button block leadingIcon={<IconDownload size={16} />} style={{ marginTop: 'auto' }}>
<Button block leadingIcon={<IconDownload size={16} />} style={{ marginTop: 'auto' }}
onClick={() => persist('published')} disabled={busy || loading}>
</Button>
</aside>

View File

@ -1,10 +1,13 @@
/*
* SCR-37 [M17 / F069]. 참조: design.md §3 SCR-37.
* 상단: 언어 ( 100· 82· 60· 45) · : 번역 ( × 상태: 미번역/AI /, AI ) · : 병렬 ()·· .
* M17·AI (Claude) ("샘플 데이터" ). API .
* 상단: 언어 (///) · : 번역 ( × ) · : 병렬 ( KO ) + .
* API 전환: GET /api/cms/contents + GET/PUT /api/cms/contents/{id}/translations. upsert· .
* AI / (AiTextRouter ) disabled + .
*/
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '../../components/ui/Button';
import { ApiRequestError } from '../../api/client';
import { cmsApi, type CmsContent, type CmsTranslation, type Lang, type TransStatus } from './cmsApi';
import { IconSpark, IconWarning, IconClose } from '../../components/ui/icons';
import './cms.css';
@ -14,129 +17,146 @@ const TARGETS: { key: Target; label: string }[] = [
{ key: 'zh', label: 'Chinese (ZH)' },
{ key: 'ja', label: 'Japanese (JA)' },
];
const COVERAGE = [
{ label: '한국어 (KO)', pct: 100 },
{ label: 'English (EN)', pct: 82 },
{ label: 'Chinese (ZH)', pct: 60 },
{ label: 'Japanese (JA)', pct: 45 },
const COV_LANGS: { key: Lang; label: string }[] = [
{ key: 'ko', label: '한국어 (KO)' },
{ key: 'en', label: 'English (EN)' },
{ key: 'zh', label: 'Chinese (ZH)' },
{ key: 'ja', label: 'Japanese (JA)' },
];
type TransStatus = 'none' | 'ai' | 'reviewed';
const STATUS_META: Record<TransStatus, { label: string; cls: string }> = {
none: { label: '미번역', cls: 'kx-cms-pill--none' },
ai: { label: 'AI 초벌', cls: 'kx-cms-pill--ai' },
reviewed: { label: '검수완료', cls: 'kx-cms-pill--published' },
};
interface Row {
id: string;
key: string;
source: string;
en: { text: string; status: TransStatus; updated: string };
}
const SAMPLE_ROWS: Row[] = [
{
id: 'GLOBAL_EVENT_NAME',
key: '행사명',
source: 'KINTEX 글로벌 스마트테크 엑스포 2026',
en: { text: 'KINTEX Global Smart Tech Expo 2026', status: 'reviewed', updated: '2시간 전' },
},
{
id: 'HALL_GUIDE',
key: '홀 안내',
source: '전시 홀 층별 안내',
en: { text: 'Exhibition Hall Floor Information', status: 'ai', updated: '1일 전' },
},
{
id: 'REGISTRATION',
key: '참가 신청',
source: '참가 신청 및 등록 절차',
en: { text: '', status: 'none', updated: '-' },
},
{
id: 'EXHIBITION_OVERVIEW',
key: '전시 개요',
source: '전시 개요 및 참가 안내',
en: {
text: 'Overview of the upcoming tech showcase and participation guidelines.',
status: 'ai',
updated: '3시간 전',
},
},
{
id: 'FLOOR_MAP_ALT',
key: '전시장 안내도',
source: '제1·2전시장 부스 배치도',
en: {
text: 'Detailed layout and booth mapping for KINTEX 1 and 2.',
status: 'reviewed',
updated: '5일 전',
},
},
];
const GLOSSARY = [
{ ko: '홀', en: 'Hall' },
{ ko: '안내', en: 'Guide' },
{ ko: '부스', en: 'Booth' },
];
const AI_DISABLED_HINT = 'AI 자동 번역(AiTextRouter/Claude)은 아직 배선되지 않았습니다.';
type TransMap = Record<string, Partial<Record<Lang, CmsTranslation>>>;
function fmtRel(iso: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleDateString('ko-KR', { dateStyle: 'medium' });
}
export function MultilingualCmsPage() {
const [target, setTarget] = useState<Target>('en');
const [rows, setRows] = useState<Row[]>(SAMPLE_ROWS);
const [selectedId, setSelectedId] = useState('GLOBAL_EVENT_NAME');
const [translating, setTranslating] = useState(false);
const [contents, setContents] = useState<CmsContent[]>([]);
const [transMap, setTransMap] = useState<TransMap>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draftText, setDraftText] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await cmsApi.listContents({ size: 100 });
const list = res.items;
const pairs = await Promise.all(
list.map(async (c) => {
try {
const trs = await cmsApi.getTranslations(c.id);
const byLang: Partial<Record<Lang, CmsTranslation>> = {};
trs.forEach((t) => (byLang[t.lang] = t));
return [c.id, byLang] as const;
} catch {
return [c.id, {}] as const;
}
}),
);
if (cancelled) return;
const map: TransMap = {};
pairs.forEach(([id, byLang]) => (map[id] = byLang));
setContents(list);
setTransMap(map);
setSelectedId(list[0]?.id ?? null);
} catch (e) {
if (!cancelled) setError(e instanceof ApiRequestError ? e.message : '번역 데이터를 불러오지 못했습니다.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const selected = useMemo(
() => rows.find((r) => r.id === selectedId) ?? rows[0],
[rows, selectedId],
() => contents.find((c) => c.id === selectedId) ?? null,
[contents, selectedId],
);
function aiTranslateAll() {
setTranslating(true);
window.setTimeout(() => {
setRows((prev) =>
prev.map((r) =>
r.en.status === 'none'
? { ...r, en: { ...r.en, text: `[AI] ${r.source}`, status: 'ai', updated: '방금' } }
: r,
),
);
setTranslating(false);
}, 900);
}
const coverage = useMemo(() => {
const total = contents.length || 1;
return COV_LANGS.map((l) => {
const covered = contents.filter((c) => {
const t = transMap[c.id]?.[l.key];
return t && t.transStatus !== 'none' && !!(t.title || t.body);
}).length;
return { label: l.label, pct: Math.round((covered / total) * 100) };
});
}, [contents, transMap]);
function updateSelectedText(text: string) {
setRows((prev) =>
prev.map((r) => (r.id === selected.id ? { ...r, en: { ...r.en, text } } : r)),
);
// 선택/대상 변경 시 편집 텍스트 동기화
useEffect(() => {
if (!selected) {
setDraftText('');
return;
}
setDraftText(transMap[selected.id]?.[target]?.title ?? '');
}, [selected, target, transMap]);
async function save(status: TransStatus) {
if (!selected) return;
setBusy(true);
setError(null);
try {
const rows = await cmsApi.saveTranslation(selected.id, {
lang: target,
title: draftText,
body: transMap[selected.id]?.[target]?.body ?? '',
transStatus: status,
});
const byLang: Partial<Record<Lang, CmsTranslation>> = {};
rows.forEach((t) => (byLang[t.lang] = t));
setTransMap((prev) => ({ ...prev, [selected.id]: byLang }));
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '번역 저장에 실패했습니다.');
} finally {
setBusy(false);
}
function applyReviewed() {
setRows((prev) =>
prev.map((r) =>
r.id === selected.id ? { ...r, en: { ...r.en, status: 'reviewed', updated: '방금' } } : r,
),
);
}
return (
<div className="kx-page">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title">
<span className="kx-sample-badge">
<IconWarning size={12} />
</span>
</h1>
<p className="kx-cms__subtitle"> · AI (Claude) · · </p>
<h1 className="kx-cms__title"> </h1>
<p className="kx-cms__subtitle"> · · · </p>
</div>
</header>
{error && (
<div className="kx-cms-alert" role="alert">
<IconWarning size={14} /> {error}
</div>
)}
{/* 커버리지 요약 */}
<div className="kx-i18n-cov" aria-label="언어 커버리지">
{COVERAGE.map((c) => (
{coverage.map((c) => (
<div className="kx-i18n-covcard" key={c.label}>
<div className="kx-i18n-covcard__top">
<span>{c.label}</span>
@ -166,64 +186,72 @@ export function MultilingualCmsPage() {
</button>
))}
</div>
<Button variant="ai" leadingIcon={<IconSpark size={16} />} onClick={aiTranslateAll} disabled={translating}>
{translating ? '번역 중…' : 'AI 자동 번역'}
<Button variant="ai" leadingIcon={<IconSpark size={16} />} disabled title={AI_DISABLED_HINT}>
AI
</Button>
</div>
<div className="kx-table-scroll">
{loading ? (
<div className="kx-cms-empty"> </div>
) : contents.length === 0 ? (
<div className="kx-cms-empty"> .</div>
) : (
<table className="kx-i18n-table">
<thead>
<tr>
<th style={{ width: '26%' }}> / (KO)</th>
<th> (Target)</th>
<th className="kx-i18n-cell--c" style={{ width: 96 }}>
</th>
<th className="kx-i18n-cell--r" style={{ width: 96 }}>
</th>
<th style={{ width: '30%' }}> / (KO)</th>
<th> ({target.toUpperCase()})</th>
<th className="kx-i18n-cell--c" style={{ width: 96 }}></th>
<th className="kx-i18n-cell--r" style={{ width: 96 }}> </th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const m = STATUS_META[r.en.status];
{contents.map((c) => {
const t = transMap[c.id]?.[target];
const st = t?.transStatus ?? 'none';
const m = STATUS_META[st];
return (
<tr
key={r.id}
className={r.id === selected.id ? 'is-active' : ''}
onClick={() => setSelectedId(r.id)}
key={c.id}
className={c.id === selectedId ? 'is-active' : ''}
onClick={() => setSelectedId(c.id)}
>
<td>
<div className="kx-i18n-row__key">{r.key}</div>
<div className="kx-i18n-row__id">{r.id}</div>
<div className="kx-i18n-row__key">{c.title}</div>
<div className="kx-i18n-row__id">{c.id}</div>
</td>
<td className={r.en.text ? '' : 'kx-i18n-row__trans--empty'}>
{r.en.text || '번역 대기 중…'}
<td className={t?.title ? '' : 'kx-i18n-row__trans--empty'}>
{t?.title || '번역 대기 중…'}
</td>
<td className="kx-i18n-cell--c">
<span className={`kx-cms-pill ${m.cls}`}>{m.label}</span>
</td>
<td className="kx-i18n-cell--r">{r.en.updated}</td>
<td className="kx-i18n-cell--r">{fmtRel(t?.updatedAt ?? null)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</section>
{/* 우: 병렬 편집기 */}
<aside className="kx-card kx-i18n-editor" aria-label="번역 편집">
<div className="kx-i18n-editor__head">
<span className="kx-cms-sublabel"> 편집: 원문() ()</span>
<button className="kx-icon-btn" title="닫기" aria-label="편집 닫기">
<span className="kx-cms-sublabel"> 편집: 원문(KO) ({target.toUpperCase()})</span>
<button className="kx-icon-btn" title="닫기" aria-label="편집 닫기" onClick={() => setSelectedId(null)}>
<IconClose size={16} />
</button>
</div>
{!selected ? (
<div className="kx-cms-empty"> .</div>
) : (
<>
<div className="kx-cms-field">
<span className="kx-cms-label">Selected ID</span>
<span className="kx-cms-label">Content ID</span>
<div className="kx-i18n-row__id" style={{ color: 'var(--color-primary-700)', fontWeight: 700 }}>
{selected.id}
</div>
@ -231,23 +259,19 @@ export function MultilingualCmsPage() {
<div className="kx-cms-field">
<span className="kx-cms-label"> (KO)</span>
<div className="kx-i18n-src">{selected.source}</div>
<div className="kx-i18n-src">{transMap[selected.id]?.ko?.title || selected.title}</div>
</div>
<div className="kx-cms-field">
<span className="kx-cms-label"> (EN)</span>
<span className="kx-cms-label"> ({target.toUpperCase()})</span>
<textarea
className="kx-cms-textarea kx-i18n-editor__ta"
value={selected.en.text}
onChange={(e) => updateSelectedText(e.target.value)}
placeholder="번역을 입력하거나 AI 재생성하세요"
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder="번역을 입력하세요"
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="ghost"
leadingIcon={<IconSpark size={14} />}
onClick={() => updateSelectedText(`[AI] ${selected.source}`)}
>
<Button variant="ghost" leadingIcon={<IconSpark size={14} />} disabled title={AI_DISABLED_HINT}>
AI
</Button>
</div>
@ -265,21 +289,16 @@ export function MultilingualCmsPage() {
</div>
</div>
<div className="kx-cms-field">
<span className="kx-cms-sublabel">Tone Guide</span>
<div className="kx-i18n-tone">
<div className="kx-i18n-tone__t"> (Formal Business)</div>
<p className="kx-i18n-tone__p">
AI 추천: 공식
.
</p>
</div>
</div>
<div className="kx-i18n-editor__actions">
<Button variant="secondary"></Button>
<Button onClick={applyReviewed}> · </Button>
<Button variant="secondary" onClick={() => save('ai')} disabled={busy}>
</Button>
<Button onClick={() => save('reviewed')} disabled={busy}>
·
</Button>
</div>
</>
)}
</aside>
</div>
</div>

View File

@ -1126,3 +1126,57 @@
align-items: center;
gap: 6px;
}
/* ── M17 실 API 전환 보강(상태/알림/빈상태/미배선 힌트) ───────────────────── */
.kx-cms-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 10px;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent);
color: var(--color-error);
font-size: 13px;
}
.kx-cms-alert--ok {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border-color: color-mix(in srgb, var(--color-success) 40%, transparent);
color: var(--color-success);
}
.kx-cms-empty {
padding: 28px 16px;
text-align: center;
color: var(--color-neutral-500);
font-size: 13px;
}
.kx-cms-terminal {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
font-size: 13px;
font-weight: 600;
}
.kx-cms-disabled-hint {
margin-left: auto;
font-size: 11px;
color: var(--color-neutral-500);
border: 1px dashed var(--color-neutral-300);
border-radius: 6px;
padding: 1px 6px;
cursor: help;
}
.kx-icon-btn.is-off {
opacity: 0.4;
}
.kx-mb-status--draft {
color: var(--color-neutral-500);
}
.kx-mb-status--draft .kx-mb-status__dot {
background: var(--color-neutral-400);
}

View File

@ -0,0 +1,117 @@
/*
* M17 CMS API (SCR-35·36·37). (../../api/client) client/endpoints/types .
* 정본: 백엔드
* - GET /api/cms/contents PageResponse<CmsContent>
* - POST /api/cms/contents CmsContent( )
* - PATCH /api/cms/contents/{id}/status?value= CmsContent(· 400)
* - GET/PUT /api/cms/contents/{id}/translations CmsTranslation[]
* - GET/PUT /api/exhibitors/{exhibitorId}/microsite Microsite
* - GET /api/public/microsites/{exhibitorId} Microsite(·)
*/
import { api } from '../../api/client';
import type { PageResponse } from '../../api/types';
export type FlowStatus = 'draft' | 'review' | 'approved' | 'published';
export type TransStatus = 'none' | 'ai' | 'reviewed';
export type Lang = 'ko' | 'en' | 'zh' | 'ja';
export interface CmsContent {
id: string;
eventId: string | null;
contentType: string | null;
title: string;
body: string | null;
status: FlowStatus;
lang: string | null;
scheduledAt: string | null;
signage: boolean;
mailing: boolean;
authorName: string | null;
publishedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface CmsTranslation {
contentId: string;
lang: Lang;
title: string | null;
body: string | null;
transStatus: TransStatus;
updatedAt: string | null;
}
export interface MicrositeSection {
id: string;
type: string;
visible: boolean;
order?: number;
payload?: Record<string, unknown>;
}
export interface Microsite {
exhibitorId: string;
eventId: string | null;
slug: string | null;
exhibitorName: string | null;
theme: string;
sections: MicrositeSection[];
seoTitle: string | null;
seoMeta: string | null;
langs: string | null;
status: 'draft' | 'published';
publishedAt: string | null;
updatedAt: string | null;
}
export interface ContentCreateRequest {
eventId?: string | null;
contentType?: string;
title: string;
body?: string;
lang?: string;
}
export interface TranslationSaveRequest {
lang: Lang;
title?: string | null;
body?: string | null;
transStatus?: TransStatus;
}
export interface MicrositeSaveRequest {
eventId?: string | null;
slug?: string | null;
exhibitorName?: string | null;
theme?: string;
sections?: MicrositeSection[];
seoTitle?: string | null;
seoMeta?: string | null;
langs?: string | null;
status?: 'draft' | 'published';
}
export const cmsApi = {
listContents: (p: { status?: string; type?: string; keyword?: string; page?: number; size?: number } = {}) => {
const qs = new URLSearchParams();
if (p.status) qs.set('status', p.status);
if (p.type) qs.set('type', p.type);
if (p.keyword) qs.set('keyword', p.keyword);
qs.set('page', String(p.page ?? 0));
qs.set('size', String(p.size ?? 50));
return api.get<PageResponse<CmsContent>>(`/api/cms/contents?${qs.toString()}`);
},
createContent: (body: ContentCreateRequest) => api.post<CmsContent>('/api/cms/contents', body),
transition: (id: string, value: FlowStatus) =>
api.patch<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}/status?value=${value}`),
getTranslations: (id: string) =>
api.get<CmsTranslation[]>(`/api/cms/contents/${encodeURIComponent(id)}/translations`),
saveTranslation: (id: string, body: TranslationSaveRequest) =>
api.put<CmsTranslation[]>(`/api/cms/contents/${encodeURIComponent(id)}/translations`, body),
getMicrosite: (exhibitorId: string) =>
api.get<Microsite>(`/api/exhibitors/${encodeURIComponent(exhibitorId)}/microsite`),
saveMicrosite: (exhibitorId: string, body: MicrositeSaveRequest) =>
api.put<Microsite>(`/api/exhibitors/${encodeURIComponent(exhibitorId)}/microsite`, body),
getPublicMicrosite: (exhibitorId: string) =>
api.get<Microsite>(`/api/public/microsites/${encodeURIComponent(exhibitorId)}`, { anonymous: true }),
};

View File

@ -1,12 +1,13 @@
/*
* SCR-22 · [M6]. Stitch scr_22_docs_milestones .
* 진입: 주최자 / "서류·마일스톤" (design.md §3 SCR-22).
* : (D-150D-0) (8··D-·)
* : (D-150D-0) (·D-·)
* AI (··kxwp ) + .
* M6 . "샘플 데이터" + // 3 .
* fetch (port_ops_docs.md § ).
* API (useQuery, // 3) GET /milestones · /documents · /documents/review.
* HWP/PDF disabled + .
*/
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AiLabel, DdayChip, StatusBadge, type FlowStatus } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
@ -17,56 +18,11 @@ import {
IconTrendUp,
IconWarning,
} from '../../components/ui/icons';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { docsApi, type RequiredDocRow } from './docsApi';
import './docs.css';
type StepState = 'done' | 'active' | 'todo';
interface Milestone {
label: string;
sub: string;
state: StepState;
}
type DocAction = 'view' | 'hwp' | 'write' | 'fix';
interface RequiredDoc {
id: string;
name: string;
/** FlowStatus 또는 'pending'(준비중 — 배지 enum 밖이라 별도 표기) */
status: FlowStatus | 'pending';
dday?: number;
action?: DocAction;
}
interface AiIssue {
tone: 'warn' | 'info';
title: string;
desc: string;
}
const MILESTONES: Milestone[] = [
{ label: 'D-150 배정', sub: '완료', state: 'done' },
{ label: 'D-30 사전협의', sub: '완료', state: 'done' },
{ label: 'D-25 유틸리티', sub: '완료', state: 'done' },
{ label: 'D-7 신고서류', sub: '진행중', state: 'active' },
{ label: 'D-0 개장', sub: '대기', state: 'todo' },
];
const DOCS: RequiredDoc[] = [
{ id: 'd1', name: '행사운영계획서', status: 'approved', action: 'view' },
{ id: 'd2', name: '부스배치도', status: 'submitted', action: 'hwp' },
{ id: 'd3', name: '재해대처계획서', status: 'draft', dday: 7, action: 'write' },
{ id: 'd4', name: '리깅 구조계산서', status: 'rejected', action: 'fix' },
{ id: 'd5', name: '방화관리 책임서약서', status: 'pending' },
{ id: 'd6', name: '주차관리 신청서', status: 'pending' },
{ id: 'd7', name: '보안요원 배치계획', status: 'pending' },
{ id: 'd8', name: '위험물 반입신고서', status: 'pending' },
];
const AI_ISSUES: AiIssue[] = [
{
tone: 'warn',
title: '데이터 불일치 감지',
desc: '부스배치도 부스 수 486 ≠ 운영계획서 510 (불일치)',
},
{ tone: 'info', title: '필수요소 누락', desc: '재해대처계획서 필수요소 누락 2건' },
];
const ACTION_LABEL: Record<DocAction, string> = {
view: '보기',
@ -75,61 +31,90 @@ const ACTION_LABEL: Record<DocAction, string> = {
fix: '수정',
};
/** 로컬 샘플 데이터의 로딩/성공/에러 3상태를 시뮬레이션(실 API 호출 없음). */
function useSampleLoad<T>(value: T) {
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const load = useCallback(() => {
setStatus('loading');
const t = setTimeout(() => setStatus('success'), 320);
return () => clearTimeout(t);
}, []);
useEffect(load, [load]);
return { status, data: status === 'success' ? value : null, retry: load };
}
const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)';
interface DocsData {
docs: RequiredDoc[];
issues: AiIssue[];
progressPct: number;
/** 서류 상태 → 리스트 액션 파생. pending 은 액션 없음(준비중 표기). */
function actionFor(status: RequiredDocRow['status']): DocAction | undefined {
switch (status) {
case 'approved':
return 'view';
case 'submitted':
return 'hwp';
case 'draft':
return 'write';
case 'rejected':
return 'fix';
default:
return undefined;
}
}
const DOCS_DATA: DocsData = { docs: DOCS, issues: AI_ISSUES, progressPct: 62.5 };
/**
* SCR-22 · .
* onOpenAuthoring: [] SCR-23 ( AppShell ).
* onOpenAuthoring: [] SCR-23 ( App ).
*/
export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docId: string) => void }) {
const { status, data, retry } = useSampleLoad(DOCS_DATA);
export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docType: string) => void }) {
const eventId = useResolvedEventId();
const [toast, setToast] = useState<string | null>(null);
const milestonesQ = useQuery({
queryKey: ['docs-milestones', eventId],
queryFn: () => docsApi.milestones(eventId as string),
enabled: !!eventId,
retry: false,
});
const docsQ = useQuery({
queryKey: ['docs-documents', eventId],
queryFn: () => docsApi.documents(eventId as string),
enabled: !!eventId,
retry: false,
});
const reviewQ = useQuery({
queryKey: ['docs-review', eventId],
queryFn: () => docsApi.review(eventId as string),
enabled: !!eventId,
retry: false,
});
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2200);
return () => clearTimeout(t);
}, [toast]);
function handleAction(doc: RequiredDoc) {
if (doc.action === 'write') {
if (onOpenAuthoring) onOpenAuthoring(doc.id);
const milestones = milestonesQ.data ?? [];
const nextDday = useMemo(() => {
const active = milestones.find((m) => m.state === 'active') ?? milestones.find((m) => m.state === 'todo');
return active?.dueDate ? daysUntil(active.dueDate) : null;
}, [milestones]);
function handleAction(doc: RequiredDocRow, action: DocAction) {
if (action === 'write' || action === 'fix') {
if (onOpenAuthoring) onOpenAuthoring(doc.docType);
else setToast(`${doc.name} 작성 화면(SCR-23)으로 이동`);
return;
}
setToast(`${doc.name} · ${ACTION_LABEL[doc.action ?? 'view']} (샘플)`);
if (action === 'view') {
setToast(`${doc.name} · 보기`);
}
}
const activeIdx = MILESTONES.findIndex((m) => m.state === 'active');
const fillPct = activeIdx <= 0 ? 0 : (activeIdx / (MILESTONES.length - 1)) * 100;
const activeIdx = milestones.findIndex((m) => m.state === 'active');
const fillPct = activeIdx <= 0 ? 0 : (activeIdx / (milestones.length - 1)) * 100;
const listStatus: 'loading' | 'error' | 'success' = docsQ.isPending
? 'loading'
: docsQ.isError
? 'error'
: 'success';
const docs = docsQ.data ?? [];
return (
<div className="kx-page">
<header className="kx-doc__head">
<div>
<h1 className="kx-doc__title">·</h1>
<p className="kx-doc__subtitle"> · AI 2026 </p>
</div>
<div className="kx-doc__head-badges">
<span className="kx-bi__degraded"> </span>
<p className="kx-doc__subtitle"> · AI </p>
</div>
</header>
@ -139,8 +124,16 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
<span className="kx-doc__miles-title">
<IconDocument size={18} />
</span>
<DdayChip dday={7} />
{nextDday != null && nextDday >= 0 && <DdayChip dday={nextDday} />}
</div>
{milestonesQ.isPending ? (
<Skeleton height={72} />
) : milestones.length === 0 ? (
<EmptyState
title="구성된 마일스톤이 없습니다"
description="행사를 생성하면 마일스톤이 자동 구성됩니다."
/>
) : (
<div className="kx-doc__stepper">
<span className="kx-doc__track" aria-hidden="true" />
<span
@ -148,7 +141,7 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
aria-hidden="true"
style={{ width: `calc((100% - var(--space-5) * 2) * ${fillPct / 100})` }}
/>
{MILESTONES.map((m) => (
{milestones.map((m) => (
<div key={m.label} className={`kx-doc__step is-${m.state}`}>
<span className="kx-doc__node">
{m.state === 'done' ? (
@ -164,10 +157,11 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
</div>
))}
</div>
)}
</section>
{/* 로딩 3상태 */}
{status === 'loading' && (
{/* 서류 리스트 3상태 */}
{listStatus === 'loading' && (
<div className="kx-doc__grid" aria-busy="true">
<section className="kx-card">
{Array.from({ length: 6 }).map((_, i) => (
@ -182,11 +176,11 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
</div>
)}
{status === 'error' && (
<ErrorState message="서류 현황을 불러오지 못했습니다." onRetry={retry} />
{listStatus === 'error' && (
<ErrorState message="서류 현황을 불러오지 못했습니다." onRetry={() => docsQ.refetch()} />
)}
{status === 'success' && data && (
{listStatus === 'success' && (
<div className="kx-doc__grid">
{/* 신고서류 체크리스트 */}
<section className="kx-card kx-doc__list" aria-label="신고서류 체크리스트">
@ -195,16 +189,16 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
<h2> </h2>
<p> </p>
</div>
<ChecklistProgress docs={data.docs} />
<ChecklistProgress docs={docs} />
</div>
{data.docs.length === 0 ? (
{docs.length === 0 ? (
<EmptyState
title="구성된 서류가 없습니다"
description="행사를 생성하면 마일스톤·서류가 자동 구성됩니다."
/>
) : (
data.docs.map((doc) => <DocRow key={doc.id} doc={doc} onAction={handleAction} />)
docs.map((doc) => <DocRow key={doc.docType} doc={doc} onAction={handleAction} />)
)}
</section>
@ -216,17 +210,23 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
<h2>AI </h2>
<AiLabel>AI </AiLabel>
</div>
{data.issues.map((iss) => (
{reviewQ.isPending ? (
<Skeleton height={96} />
) : (reviewQ.data?.issues ?? []).length === 0 ? (
<p className="kx-doc__issue-desc"> · .</p>
) : (
(reviewQ.data?.issues ?? []).map((iss) => (
<div key={iss.title} className={`kx-doc__issue kx-doc__issue--${iss.tone}`}>
<span className="kx-doc__issue-icon" aria-hidden="true">
<IconWarning size={18} />
</span>
<div>
<p className="kx-doc__issue-title">{iss.title}</p>
<p className="kx-doc__issue-desc">{iss.desc}</p>
<p className="kx-doc__issue-desc">{iss.description}</p>
</div>
</div>
))}
))
)}
<div className="kx-doc__note">
<p className="kx-doc__note-title">
<IconDocument size={14} /> kxwp
@ -240,7 +240,8 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
variant="ai"
block
leadingIcon={<IconSpark size={16} />}
onClick={() => setToast('AI 자동 문서 생성 요청 (샘플)')}
disabled
title={RENDER_TOOLTIP}
>
</Button>
@ -248,9 +249,11 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
<section className="kx-doc__stat" aria-label="전체 공정률">
<p className="kx-doc__stat-label"> </p>
<p className="kx-doc__stat-value tnum">{data.progressPct}%</p>
<p className="kx-doc__stat-value tnum">
{reviewQ.isPending ? '—' : `${reviewQ.data?.progressPct ?? 0}%`}
</p>
<p className="kx-doc__stat-delta">
<IconTrendUp size={16} /> 12%
<IconTrendUp size={16} />
</p>
</section>
</div>
@ -266,9 +269,9 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docI
);
}
function ChecklistProgress({ docs }: { docs: RequiredDoc[] }) {
/** 준비중(pending) 제외한 진척 집계. */
function ChecklistProgress({ docs }: { docs: RequiredDocRow[] }) {
const total = docs.length;
// '준비중(pending)'을 제외한 진행/완료 건을 진척으로 집계.
const done = docs.filter((d) => d.status !== 'pending').length;
const pct = total ? Math.round((done / total) * 100) : 0;
return (
@ -283,22 +286,27 @@ function ChecklistProgress({ docs }: { docs: RequiredDoc[] }) {
);
}
function DocRow({ doc, onAction }: { doc: RequiredDoc; onAction: (d: RequiredDoc) => void }) {
function DocRow({
doc,
onAction,
}: {
doc: RequiredDocRow;
onAction: (d: RequiredDocRow, action: DocAction) => void;
}) {
const isPending = doc.status === 'pending';
const action = actionFor(doc.status);
const rowCls = [
'kx-doc__row',
doc.action === 'write' ? 'kx-doc__row--active' : '',
action === 'write' ? 'kx-doc__row--active' : '',
isPending ? 'kx-doc__row--muted' : '',
]
.filter(Boolean)
.join(' ');
const btnVariant =
doc.action === 'write'
? 'primary'
: doc.action === 'fix'
? 'danger'
: 'secondary';
action === 'write' ? 'primary' : action === 'fix' ? 'danger' : 'secondary';
// HWP/PDF 렌더 액션은 파일 큐 후속 → 비활성(툴팁).
const isRenderAction = action === 'hwp';
return (
<div className={rowCls}>
@ -310,17 +318,30 @@ function DocRow({ doc, onAction }: { doc: RequiredDoc; onAction: (d: RequiredDoc
{doc.dday != null && <DdayChip dday={doc.dday} />}
</div>
<div className="kx-doc__row-actions">
{doc.status === 'pending' ? (
{isPending ? (
<span className="kx-doc__pill-pending"></span>
) : (
<StatusBadge status={doc.status} />
<StatusBadge status={doc.status as FlowStatus} />
)}
{doc.action && (
<Button variant={btnVariant} onClick={() => onAction(doc)}>
{ACTION_LABEL[doc.action]}
{action && (
<Button
variant={btnVariant}
disabled={isRenderAction}
title={isRenderAction ? RENDER_TOOLTIP : undefined}
onClick={() => (isRenderAction ? undefined : onAction(doc, action))}
>
{ACTION_LABEL[action]}
</Button>
)}
</div>
</div>
);
}
/** dueDate(YYYY-MM-DD)까지 남은 일수(음수 허용). */
function daysUntil(dateStr: string): number {
const due = new Date(`${dateStr}T00:00:00`);
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((due.getTime() - today.getTime()) / 86_400_000);
}

View File

@ -3,15 +3,34 @@
* 진입: SCR-22 · "[작성]" (design.md §3 SCR-23).
* : ( + AI + ) A4
* ( ·HWP ·PDF · / kxwp ).
* M6· 릿 . // 3 , fetch .
* / API(POST /documents/{docType} save|submit) .
* HWP/PDF disabled + .
*/
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { IconChevronDown, IconSpark, IconWarning } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { docsApi, type DocAction } from './docsApi';
import './docs.css';
const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)';
/** 서류 표시명 → doc_type 코드(백엔드 required_document.doc_type). 미매핑 시 재해대처계획서. */
const DOC_TYPE_CODE: Record<string, string> = {
: 'operation_plan',
: 'booth_layout',
: 'disaster_plan',
'리깅 구조계산서': 'rigging_calc',
'방화관리 책임서약서': 'fire_safety',
'주차관리 신청서': 'parking',
'보안요원 배치계획': 'security',
'위험물 반입신고서': 'hazmat',
};
interface ReportForm {
eventName: string;
eventDate: string;
@ -90,6 +109,9 @@ function fmtDate(v: string): string {
export function ReportAuthoringPage({ docType = '재해대처계획서' }: { docType?: string }) {
const { status, retry } = useSampleReady();
const eventId = useResolvedEventId();
const queryClient = useQueryClient();
const docTypeCode = DOC_TYPE_CODE[docType] ?? 'disaster_plan';
const [form, setForm] = useState<ReportForm>(EMPTY_FORM);
const [open, setOpen] = useState<Record<SectionId, boolean>>({
overview: true,
@ -117,6 +139,33 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
};
const hasError = Object.values(invalid).some(Boolean);
const transition = useMutation({
mutationFn: (action: DocAction) =>
docsApi.transition(eventId as string, docTypeCode, action),
onSuccess: (_data, action) => {
// 목록(SCR-22) 캐시 무효화 — 되돌아갈 때 상태 최신화.
queryClient.invalidateQueries({ queryKey: ['docs-documents', eventId] });
queryClient.invalidateQueries({ queryKey: ['docs-review', eventId] });
setToast(
action === 'submit'
? '제출 완료 — kxwp에 업로드할 파일을 생성하세요.'
: '임시 저장 완료',
);
},
onError: (e) => {
const msg = e instanceof ApiRequestError ? e.message : '저장에 실패했습니다.';
setToast(msg);
},
});
function saveDraft() {
if (!eventId) {
setToast('대상 행사를 확인할 수 없습니다.');
return;
}
transition.mutate('save');
}
function submit() {
if (hasError) {
setShowErrors(true);
@ -124,7 +173,11 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
setToast('필수 항목을 입력하세요.');
return;
}
setToast('제출용 파일 생성 완료 — kxwp에 업로드하세요. (샘플)');
if (!eventId) {
setToast('대상 행사를 확인할 수 없습니다.');
return;
}
transition.mutate('submit');
}
if (status === 'loading') {
@ -157,9 +210,6 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
<h1 className="kx-doc__title">{docType} </h1>
<p className="kx-doc__subtitle"> · HWP/PDF</p>
</div>
<div className="kx-doc__head-badges">
<span className="kx-bi__degraded"> </span>
</div>
</header>
<div className="kx-rep">
@ -420,16 +470,18 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
<IconWarning size={16} /> kxwp
</p>
<div className="kx-rep__bar-actions">
<Button variant="ghost" onClick={() => setToast('임시 저장 완료 (샘플)')}>
<Button variant="ghost" onClick={saveDraft} disabled={transition.isPending}>
</Button>
<Button variant="secondary" onClick={() => setToast('HWP 파일 생성 (샘플)')}>
<Button variant="secondary" disabled title={RENDER_TOOLTIP}>
HWP
</Button>
<Button variant="secondary" onClick={() => setToast('PDF 파일 생성 (샘플)')}>
<Button variant="secondary" disabled title={RENDER_TOOLTIP}>
PDF
</Button>
<Button onClick={submit}></Button>
<Button onClick={submit} disabled={transition.isPending}>
</Button>
</div>
</div>

View File

@ -0,0 +1,56 @@
/*
* M6 ·(SCR-22/23) API .
* (../../api/client) client.ts·endpoints.ts·types.ts .
* 정본: 백엔드 DocumentController
* - GET /api/events/{eventId}/milestones ApiResponse<MilestoneRow[]>
* - GET /api/events/{eventId}/documents ApiResponse<RequiredDocRow[]>
* - GET /api/events/{eventId}/documents/review ApiResponse<DocReview>
* - POST /api/events/{eventId}/documents/{docType} ApiResponse<RequiredDocRow> (action: save|submit)
* HWP/PDF ( disabled ).
*/
import { api } from '../../api/client';
export type MilestoneState = 'done' | 'active' | 'todo';
export interface MilestoneRow {
label: string;
sub: string;
state: MilestoneState;
dueDate: string | null;
}
export type DocStatus = 'pending' | 'draft' | 'submitted' | 'approved' | 'rejected';
export interface RequiredDocRow {
docType: string;
name: string;
status: DocStatus;
dueDate: string | null;
dday: number | null;
sortOrder: number;
}
export interface ReviewIssueRow {
tone: 'warn' | 'info';
title: string;
description: string;
}
export interface DocReview {
issues: ReviewIssueRow[];
progressPct: number;
}
/** save→임시저장(draft), submit→제출(submitted). */
export type DocAction = 'save' | 'submit';
export const docsApi = {
milestones: (eventId: string) =>
api.get<MilestoneRow[]>(`/api/events/${encodeURIComponent(eventId)}/milestones`),
documents: (eventId: string) =>
api.get<RequiredDocRow[]>(`/api/events/${encodeURIComponent(eventId)}/documents`),
review: (eventId: string) =>
api.get<DocReview>(`/api/events/${encodeURIComponent(eventId)}/documents/review`),
transition: (eventId: string, docType: string, action: DocAction) =>
api.post<RequiredDocRow>(
`/api/events/${encodeURIComponent(eventId)}/documents/${encodeURIComponent(docType)}`,
{ action },
),
};

View File

@ -1,30 +1,59 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconImage, IconPlus, IconSpark } from '../../components/ui/icons';
import {
CAMPAIGN_KPIS,
CAMPAIGN_STATUS_LABEL,
CAMPAIGNS,
SEGMENTS,
type Campaign,
type CampaignStatus,
} from './sampleMarketing';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { CAMPAIGN_STATUS_LABEL, CAMPAIGNS, SEGMENTS, type CampaignStatus } from './sampleMarketing';
import { marketingApi, type CampaignDto } from './marketingApi';
import './marketing.css';
/*
* SCR-33 EDM· (M12). Stitch scr_33_edm_campaign .
* M12 API , sampleMarketing .
* · .
* 경로: GET /api/events/{eventId}/campaigns () · POST /campaigns ( , status ).
* KPI . 폴백: 오프라인 sampleMarketing.CAMPAIGNS.
* · . ( PII ).
*/
export function EdmCampaignPage() {
const eventId = useResolvedEventId();
const queryClient = useQueryClient();
const [filter, setFilter] = useState<'all' | 'active' | 'done'>('all');
const [segments, setSegments] = useState<Record<string, boolean>>(
Object.fromEntries(SEGMENTS.map((s) => [s.id, s.defaultOn])),
);
const [schedule, setSchedule] = useState<'now' | 'later'>('now');
const [name, setName] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const rows = CAMPAIGNS.filter((c) =>
const q = useQuery({
queryKey: ['campaigns', eventId],
queryFn: () => marketingApi.campaigns(eventId as string),
enabled: !!eventId,
retry: false,
});
const degraded = isDegradable(q.error);
const campaigns: CampaignDto[] = q.data ?? (degraded ? SAMPLE_CAMPAIGNS : []);
const hardError = q.isError && !degraded;
const createMut = useMutation({
mutationFn: (body: Parameters<typeof marketingApi.createCampaign>[1]) =>
marketingApi.createCampaign(eventId as string, body),
onSuccess: () => {
setName('');
setScheduledAt('');
queryClient.invalidateQueries({ queryKey: ['campaigns', eventId] });
},
});
const audience = useMemo(
() => SEGMENTS.filter((s) => segments[s.id]).reduce((sum, s) => sum + s.count, 0),
[segments],
);
const rows = campaigns.filter((c) =>
filter === 'all'
? true
: filter === 'done'
@ -32,6 +61,24 @@ export function EdmCampaignPage() {
: c.status === 'scheduled' || c.status === 'sending',
);
const kpis = [
{ label: '전체 캠페인', value: String(campaigns.length), unit: '개', tone: 'primary' as const },
{ label: '진행중', value: String(campaigns.filter((c) => c.status === 'scheduled' || c.status === 'sending').length), unit: '개', tone: 'ai' as const },
{ label: '완료', value: String(campaigns.filter((c) => c.status === 'done').length), unit: '개', tone: 'success' as const },
{ label: '초안', value: String(campaigns.filter((c) => c.status === 'draft').length), unit: '개', tone: 'warning' as const },
];
const canCreate = !!eventId && name.trim().length > 0 && !createMut.isPending;
function submitCampaign() {
if (!canCreate) return;
createMut.mutate({
name: name.trim(),
audience,
scheduleType: schedule,
scheduledAt: schedule === 'later' ? scheduledAt : undefined,
});
}
return (
<div className="kx-mkt">
<header className="kx-mkt__head">
@ -40,24 +87,48 @@ export function EdmCampaignPage() {
<p className="kx-mkt__subtitle"> · · AI </p>
</div>
<div className="kx-mkt__head-actions">
<span className="kx-sample" title="M12 마케팅 모듈 미연동 — 시연 데이터"> </span>
<Button leadingIcon={<IconPlus size={16} />}> </Button>
{degraded && (
<span className="kx-vis__degraded" title="캠페인 API 미연결 — 시연 데이터"> </span>
)}
<Button leadingIcon={<IconPlus size={16} />} onClick={submitCampaign} disabled={!canCreate}>
</Button>
</div>
</header>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="캠페인을 조회할 행사가 지정되지 않았습니다." />
)}
{eventId && q.isLoading && !degraded && (
<div style={{ display: 'grid', gap: 16 }}>
<Skeleton height={92} radius={12} />
<Skeleton height={420} radius={12} />
</div>
)}
{hardError && <ErrorState message="캠페인을 불러오지 못했습니다." onRetry={() => q.refetch()} />}
{eventId && (!q.isLoading || degraded) && !hardError && (
<>
<section className="kx-mkt__kpis" aria-label="캠페인 핵심 지표">
{CAMPAIGN_KPIS.map((k) => (
<div key={k.label} className={`kx-kpi kx-kpi--accent-${k.tone ?? 'primary'}`}>
{kpis.map((k) => (
<div key={k.label} className={`kx-kpi kx-kpi--accent-${k.tone}`}>
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">
{k.value}
{k.unit && <span className="kx-kpi__unit">{k.unit}</span>}
</strong>
{k.hint && <span className="kx-kpi__sub">{k.hint}</span>}
</div>
))}
</section>
{createMut.isError && (
<div className="kx-vis__degraded" role="alert" style={{ marginBottom: 12 }}>
. .
</div>
)}
<div className="kx-mkt__split">
{/* 캠페인 리스트 */}
<section className="kx-mkt__list-col" aria-label="캠페인 목록">
@ -81,6 +152,11 @@ export function EdmCampaignPage() {
{rows.map((c) => (
<CampaignRow key={c.id} campaign={c} />
))}
{rows.length === 0 && (
<li>
<EmptyState title="캠페인이 없습니다" description="우측 빌더에서 새 캠페인을 생성하세요." />
</li>
)}
</ul>
</section>
@ -92,6 +168,16 @@ export function EdmCampaignPage() {
<AiLabel>AI </AiLabel>
</div>
<p className="kx-mkt__field-label"> </p>
<input
type="text"
className="kx-mkt__datetime"
aria-label="캠페인 이름"
placeholder="예: 2026 스마트팩토리 사전등록 안내"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p className="kx-mkt__field-label"> </p>
<div className="kx-mkt__segments">
{SEGMENTS.map((s) => (
@ -106,6 +192,7 @@ export function EdmCampaignPage() {
</label>
))}
</div>
<p className="kx-mkt__field-label"> : <strong className="tnum">{audience.toLocaleString()}</strong> ( )</p>
<div className="kx-mkt__ai-box">
<div className="kx-mkt__ai-box-head">
@ -145,7 +232,13 @@ export function EdmCampaignPage() {
</button>
</div>
{schedule === 'later' && (
<input type="datetime-local" className="kx-mkt__datetime" aria-label="예약 발송 일시" />
<input
type="datetime-local"
className="kx-mkt__datetime"
aria-label="예약 발송 일시"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
/>
)}
</div>
@ -153,7 +246,7 @@ export function EdmCampaignPage() {
<div className="kx-mkt__preview-head"></div>
<div className="kx-mkt__preview-body">
<div className="kx-mkt__preview-banner"> </div>
<p className="kx-mkt__preview-title">2026 KINTEX </p>
<p className="kx-mkt__preview-title">{name.trim() || '2026 KINTEX 스마트팩토리 엑스포에 초대합니다'}</p>
<p className="kx-mkt__preview-text">
, . KINTEX가 .
</p>
@ -167,53 +260,61 @@ export function EdmCampaignPage() {
</div>
</div>
<Button block> </Button>
<Button block onClick={submitCampaign} disabled={!canCreate}>
{createMut.isPending ? '생성 중…' : schedule === 'later' ? '예약 캠페인 등록' : '캠페인 초안 저장'}
</Button>
<p className="kx-vis__ai-note" style={{ marginTop: 8 }}>
( ) · .
</p>
</div>
</aside>
</div>
</>
)}
</div>
);
}
function CampaignRow({ campaign: c }: { campaign: Campaign }) {
function CampaignRow({ campaign: c }: { campaign: CampaignDto }) {
const status = normalizeStatus(c.status);
return (
<li className="kx-mkt__campaign">
<div className="kx-mkt__campaign-main">
<span className={`kx-mkt__campaign-icon is-${c.status}`} aria-hidden="true">
<StatusGlyph status={c.status} />
<span className={`kx-mkt__campaign-icon is-${status}`} aria-hidden="true">
<StatusGlyph status={status} />
</span>
<div className="kx-mkt__campaign-info">
<div className="kx-mkt__campaign-top">
<CampaignPill status={c.status} />
<span className={`kx-mkt__cpill is-${status}`}>{CAMPAIGN_STATUS_LABEL[status]}</span>
<h3>{c.name}</h3>
</div>
<div className="kx-mkt__campaign-meta">
<span>: {c.audience.toLocaleString()}</span>
<span className="kx-mkt__dot-sep" aria-hidden="true" />
<span>{c.meta}</span>
<span>{c.meta ?? '-'}</span>
</div>
</div>
</div>
<div className="kx-mkt__campaign-metrics">
<div>
<span className="kx-mkt__metric-k"></span>
<strong>{c.openRate}</strong>
<strong>{c.openRate ?? '-'}</strong>
</div>
<div>
<span className="kx-mkt__metric-k"></span>
<strong>{c.clickRate}</strong>
<strong>{c.clickRate ?? '-'}</strong>
</div>
</div>
</li>
);
}
function CampaignPill({ status }: { status: CampaignStatus }) {
return <span className={`kx-mkt__cpill is-${status}`}>{CAMPAIGN_STATUS_LABEL[status]}</span>;
const KNOWN_STATUS: CampaignStatus[] = ['draft', 'scheduled', 'sending', 'done'];
function normalizeStatus(s: string): CampaignStatus {
return (KNOWN_STATUS as string[]).includes(s) ? (s as CampaignStatus) : 'draft';
}
function StatusGlyph({ status }: { status: CampaignStatus }) {
// 상태별 단순 선 글리프.
const common = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
return (
<svg width={20} height={20} viewBox="0 0 24 24" aria-hidden="true">
@ -233,3 +334,21 @@ function StatusGlyph({ status }: { status: CampaignStatus }) {
</svg>
);
}
/** 폴백 캠페인(오프라인 강등 시) — sampleMarketing.CAMPAIGNS 파생. */
const SAMPLE_CAMPAIGNS: CampaignDto[] = CAMPAIGNS.map((c) => ({
id: c.id,
name: c.name,
status: c.status,
audience: c.audience,
meta: c.meta,
openRate: c.openRate,
clickRate: c.clickRate,
}));
function isDegradable(error: unknown): boolean {
return (
error instanceof ApiRequestError &&
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
);
}

View File

@ -1,25 +1,44 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconArrowRight, IconCheck, IconPlus } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import {
FULFILLMENT,
SPONSOR_KPIS,
SPONSOR_STATUS_LABEL,
SPONSOR_TIERS,
SPONSORS,
type Sponsor,
type SponsorTier,
type SponsorContractStatus,
} from './sampleMarketing';
import { marketingApi, type SponsorDto, type SponsorshipViewDto, type TierDto } from './marketingApi';
import './marketing.css';
/*
* SCR-34 · (M12 / F070). Stitch scr_34_sponsorship .
* M12 API , sampleMarketing .
* 경로: GET /api/events/{eventId}/sponsorship (KPI···).
* 폴백: 오프라인 sampleMarketing .
*/
export function SponsorshipPage() {
const [selectedSponsor, setSelectedSponsor] = useState<string>(SPONSORS[0]?.id ?? '');
const active = SPONSORS.find((s) => s.id === selectedSponsor) ?? SPONSORS[0];
const eventId = useResolvedEventId();
const [selectedSponsor, setSelectedSponsor] = useState<string>('');
const q = useQuery({
queryKey: ['sponsorship', eventId],
queryFn: () => marketingApi.sponsorship(eventId as string),
enabled: !!eventId,
retry: false,
});
const degraded = isDegradable(q.error);
const view: SponsorshipViewDto | null = q.data ?? (degraded ? SAMPLE_VIEW : null);
const hardError = q.isError && !degraded;
const sponsors = view?.sponsors ?? [];
const active = sponsors.find((s) => s.id === selectedSponsor) ?? sponsors[0] ?? null;
return (
<div className="kx-mkt">
@ -29,13 +48,30 @@ export function SponsorshipPage() {
<p className="kx-mkt__subtitle"> · · </p>
</div>
<div className="kx-mkt__head-actions">
<span className="kx-sample" title="M12 스폰서십 모듈 미연동 — 시연 데이터"> </span>
{degraded && (
<span className="kx-vis__degraded" title="스폰서십 API 미연결 — 시연 데이터"> </span>
)}
<Button leadingIcon={<IconPlus size={16} />}> </Button>
</div>
</header>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="스폰서십 현황을 조회할 행사가 지정되지 않았습니다." />
)}
{eventId && q.isLoading && !degraded && (
<div style={{ display: 'grid', gap: 16 }}>
<Skeleton height={92} radius={12} />
<Skeleton height={420} radius={12} />
</div>
)}
{hardError && <ErrorState message="스폰서십 현황을 불러오지 못했습니다." onRetry={() => q.refetch()} />}
{view && (
<>
<section className="kx-mkt__kpis" aria-label="스폰서십 핵심 지표">
{SPONSOR_KPIS.map((k) => (
{view.kpis.map((k) => (
<div key={k.label} className={`kx-kpi kx-kpi--accent-${k.tone ?? 'primary'}`}>
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">
@ -54,9 +90,12 @@ export function SponsorshipPage() {
<button type="button" className="kx-mkt__link"> </button>
</div>
<div className="kx-mkt__tiers">
{SPONSOR_TIERS.map((t) => (
{view.tiers.map((t) => (
<TierCard key={t.id} tier={t} />
))}
{view.tiers.length === 0 && (
<EmptyState title="등록된 패키지가 없습니다" description="스폰서십 티어를 등록하세요." />
)}
</div>
<div className="kx-mkt__deadline">
@ -66,7 +105,7 @@ export function SponsorshipPage() {
</span>
<div>
<h3> </h3>
<p> 14 . .</p>
<p> . .</p>
</div>
</div>
<Button variant="secondary"> </Button>
@ -80,7 +119,7 @@ export function SponsorshipPage() {
<h2> </h2>
</div>
<ul className="kx-mkt__sponsors">
{SPONSORS.map((s) => (
{sponsors.map((s) => (
<li key={s.id}>
<button
type="button"
@ -91,21 +130,27 @@ export function SponsorshipPage() {
<span className="kx-mkt__sponsor-avatar" aria-hidden="true">{s.initial}</span>
<span className="kx-mkt__sponsor-info">
<strong>{s.name}</strong>
<span>{s.tier}</span>
<span>{s.tier ?? '-'}</span>
</span>
<SponsorPill sponsor={s} />
<SponsorPill status={s.status} />
</button>
</li>
))}
{sponsors.length === 0 && (
<li>
<EmptyState title="스폰서가 없습니다" description="계약된 스폰서가 여기에 표시됩니다." />
</li>
)}
</ul>
</div>
{active && (
<div className="kx-card">
<div className="kx-card__head">
<h2>{active?.name} </h2>
<h2>{active.name} </h2>
</div>
<ul className="kx-mkt__fulfillment">
{FULFILLMENT.map((f) => (
{active.fulfillment.map((f) => (
<li key={f.id} className={f.done ? 'is-done' : ''}>
<span className={`kx-mkt__check ${f.done ? 'is-done' : ''}`} aria-hidden="true">
{f.done && <IconCheck size={14} />}
@ -113,22 +158,28 @@ export function SponsorshipPage() {
{f.label}
</li>
))}
{active.fulfillment.length === 0 && (
<li className="kx-vis__activity-empty"> </li>
)}
</ul>
<button type="button" className="kx-mkt__roi-link">
<span> · · ROI </span>
<IconArrowRight size={18} />
</button>
</div>
)}
</aside>
</div>
</>
)}
</div>
);
}
function TierCard({ tier: t }: { tier: SponsorTier }) {
function TierCard({ tier: t }: { tier: TierDto }) {
const sold = t.status === 'soldout';
return (
<div className={`kx-mkt__tier ${sold ? 'is-soldout' : ''}`} style={{ ['--tier-accent' as string]: t.accent }}>
<div className={`kx-mkt__tier ${sold ? 'is-soldout' : ''}`} style={{ ['--tier-accent' as string]: t.accent ?? '#667085' }}>
<div className="kx-mkt__tier-body">
<div className="kx-mkt__tier-top">
<span className="kx-mkt__tier-badge">{t.name}</span>
@ -166,8 +217,39 @@ function TierCard({ tier: t }: { tier: SponsorTier }) {
);
}
function SponsorPill({ sponsor }: { sponsor: Sponsor }) {
const KNOWN_SPONSOR_STATUS: SponsorContractStatus[] = ['signed', 'pending'];
function SponsorPill({ status }: { status: string }) {
const s = (KNOWN_SPONSOR_STATUS as string[]).includes(status) ? (status as SponsorContractStatus) : 'pending';
return <span className={`kx-mkt__spill is-${s}`}>{SPONSOR_STATUS_LABEL[s]}</span>;
}
/** 폴백 뷰(오프라인 강등 시) — sampleMarketing 파생. */
const SAMPLE_VIEW: SponsorshipViewDto = {
kpis: SPONSOR_KPIS.map((k) => ({ label: k.label, value: k.value, unit: k.unit ?? null, tone: k.tone ?? null })),
tiers: SPONSOR_TIERS.map((t) => ({
id: t.id,
name: t.name,
nameKo: t.nameKo,
price: t.price,
accent: t.accent,
benefits: t.benefits,
remaining: t.remaining,
status: t.status,
dday: t.dday ?? null,
})),
sponsors: SPONSORS.map<SponsorDto>((s) => ({
id: s.id,
name: s.name,
initial: s.initial,
tier: s.tier,
status: s.status,
fulfillment: FULFILLMENT.map((f) => ({ id: f.id, label: f.label, done: f.done })),
})),
};
function isDegradable(error: unknown): boolean {
return (
<span className={`kx-mkt__spill is-${sponsor.status}`}>{SPONSOR_STATUS_LABEL[sponsor.status]}</span>
error instanceof ApiRequestError &&
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
);
}

View File

@ -48,6 +48,24 @@
white-space: nowrap;
}
/* 오프라인 강등(폴백) 표시 — 집계 API 미연결 시 시연 데이터 안내 */
.kx-vis__degraded {
font-size: 11px;
font-weight: 700;
color: var(--color-warning);
background: #fff4e5;
border: 1px solid #fcd9a8;
padding: 3px 10px;
border-radius: var(--radius-pill);
white-space: nowrap;
}
.kx-vis__activity-empty {
color: var(--color-text-muted, #667085);
font-size: 13px;
padding: 6px 0;
list-style: none;
}
/* 칩 */
.kx-chip {
display: inline-flex;

View File

@ -0,0 +1,76 @@
/*
* M12 EDM· · API (SCR-33·SCR-34).
* (../../api/client) client.ts·endpoints.ts .
*
* :
* GET /api/events/{eventId}/campaigns?status ApiResponse<CampaignDto[]>
* POST /api/events/{eventId}/campaigns ( status )
* GET /api/events/{eventId}/sponsorship ApiResponse<SponsorshipView>
*/
import { api } from '../../api/client';
// ── SCR-33 캠페인 ──
export interface CampaignDto {
id: string;
name: string;
status: string; // draft|scheduled|sending|done
audience: number;
meta: string | null;
openRate: string | null;
clickRate: string | null;
}
export interface CampaignCreateBody {
name: string;
audience?: number;
meta?: string;
scheduleType?: 'now' | 'later';
scheduledAt?: string;
}
// ── SCR-34 스폰서십 ──
export interface MktKpiDto {
label: string;
value: string;
unit: string | null;
tone: string | null;
}
export interface TierDto {
id: string;
name: string;
nameKo: string;
price: string;
accent: string | null;
benefits: string[];
remaining: number;
status: string; // available|soldout
dday: number | null;
}
export interface FulfillmentDto {
id: string;
label: string;
done: boolean;
}
export interface SponsorDto {
id: string;
name: string;
initial: string;
tier: string | null;
status: string; // signed|pending
fulfillment: FulfillmentDto[];
}
export interface SponsorshipViewDto {
kpis: MktKpiDto[];
tiers: TierDto[];
sponsors: SponsorDto[];
}
export const marketingApi = {
campaigns: (eventId: string, status?: string) => {
const qs = status ? `?status=${encodeURIComponent(status)}` : '';
return api.get<CampaignDto[]>(`/api/events/${encodeURIComponent(eventId)}/campaigns${qs}`);
},
createCampaign: (eventId: string, body: CampaignCreateBody) =>
api.post<CampaignDto>(`/api/events/${encodeURIComponent(eventId)}/campaigns`, body),
sponsorship: (eventId: string) =>
api.get<SponsorshipViewDto>(`/api/events/${encodeURIComponent(eventId)}/sponsorship`),
};

View File

@ -1,46 +1,23 @@
/*
* SCR-25 / [M8]. Stitch scr_25_dock_reservation .
* 진입: 통합 / "반입·반출" (design.md §3 SCR-25).
* : (··/ ) 1~6 ×
* ( · · ) (···· QR) + AI .
* . "샘플 데이터" + // 3 , fetch .
* : (/ ·) ×
* ( · · ) (···· QR) + AI .
* API (useQuery/useMutation) GET /docks · POST /dock-reservations(409 ) · GET /dock-forecast.
* , ( ).
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AiLabel } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconSpark } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { logisticsApi, type Dock, type GridReservation } from './logisticsApi';
import './movein.css';
/** 08:00 ~ 19:00 (12개 시간대) */
const HOURS = Array.from({ length: 12 }, (_, i) => `${String(8 + i).padStart(2, '0')}:00`);
const DOCKS = ['도크 1', '도크 2', '도크 3', '도크 4', '도크 5', '도크 6'];
const WEIGHTS = ['1t', '2.5t', '5t', '11t 이상'];
interface Reservation {
dock: number; // 0-based dock index
start: number; // 0-based hour column
span: number; // number of columns
label: string;
tone: 'booked' | 'priority';
}
/** 반입 기준 샘플 예약(Stitch 정합). 반출로 토글하면 별도 샘플 세트로 교체. */
const RESERVATIONS_IN: Reservation[] = [
{ dock: 0, start: 0, span: 2, label: '(주)공간디자인 · 5t 트럭', tone: 'booked' },
{ dock: 0, start: 3, span: 2, label: '중량물 우선 (5t 이상)', tone: 'priority' },
{ dock: 1, start: 4, span: 3, label: '글로벌부스테크 · 2.5t 트럭', tone: 'booked' },
{ dock: 2, start: 1, span: 2, label: '비욘드디자인 · 1t 탑차', tone: 'booked' },
{ dock: 4, start: 8, span: 2, label: '네오로지스 · 5t 윙바디', tone: 'booked' },
];
const RESERVATIONS_OUT: Reservation[] = [
{ dock: 0, start: 6, span: 3, label: '(주)공간디자인 · 철거 5t', tone: 'booked' },
{ dock: 2, start: 7, span: 2, label: '중량물 우선 (5t 이상)', tone: 'priority' },
{ dock: 3, start: 9, span: 3, label: '네오로지스 · 철거 11t', tone: 'booked' },
];
/** AI 철거일 대기열 예측(시간대별 밀집도 %) — 오후 4시경 피크. */
const QUEUE_FORECAST = [20, 30, 40, 55, 70, 95, 100, 80, 50, 30, 22, 18];
interface DockForm {
vehicleNo: string;
weight: string;
@ -49,68 +26,118 @@ interface DockForm {
}
const EMPTY_FORM: DockForm = { vehicleNo: '', weight: '5t', item: '전시 부스 자재', forklift: false };
function useSampleReady() {
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const load = useCallback(() => {
setStatus('loading');
const t = setTimeout(() => setStatus('success'), 320);
return () => clearTimeout(t);
}, []);
useEffect(load, [load]);
return { status, retry: load };
/** 시간대 라벨 배열(gridStartHour 부터 hours 개). */
function hourLabels(gridStartHour: number, hours: number): string[] {
return Array.from({ length: hours }, (_, i) => `${String(gridStartHour + i).padStart(2, '0')}:00`);
}
export function DockReservationPage() {
const { status, retry } = useSampleReady();
const eventId = useResolvedEventId();
const queryClient = useQueryClient();
const [direction, setDirection] = useState<'in' | 'out'>('in');
const [date, setDate] = useState<string>(''); // '' → 서버 기본 일자
const [form, setForm] = useState<DockForm>(EMPTY_FORM);
const [selected, setSelected] = useState<{ dock: number; hour: number } | null>(null);
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2200);
const t = setTimeout(() => setToast(null), 2600);
return () => clearTimeout(t);
}, [toast]);
const reservations = direction === 'in' ? RESERVATIONS_IN : RESERVATIONS_OUT;
const boardQ = useQuery({
queryKey: ['dock-board', eventId, direction, date],
queryFn: () => logisticsApi.board(eventId as string, direction, date || undefined),
enabled: !!eventId,
retry: false,
});
const board = boardQ.data;
const boardDate = board?.date ?? '';
const gridStartHour = board?.gridStartHour ?? 8;
const hours = board?.hours ?? 12;
const hourList = useMemo(() => hourLabels(gridStartHour, hours), [gridStartHour, hours]);
const docks: Dock[] = board?.docks ?? [];
const forecastQ = useQuery({
queryKey: ['dock-forecast', eventId, boardDate],
queryFn: () => logisticsApi.forecast(eventId as string, boardDate),
enabled: !!eventId && !!boardDate,
retry: false,
});
const reserve = useMutation({
mutationFn: () => {
if (!selected) throw new ApiRequestError('VALIDATION', '먼저 슬롯을 선택하세요.', 400);
const span = Math.min(2, hours - selected.hour);
return logisticsApi.reserve(eventId as string, {
direction,
date: boardDate,
dockIndex: selected.dock,
startHour: gridStartHour + selected.hour,
span: Math.max(1, span),
vehicleNo: form.vehicleNo || undefined,
weight: form.weight,
item: form.item || undefined,
forklift: form.forklift,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dock-board', eventId, direction, date] });
queryClient.invalidateQueries({ queryKey: ['dock-forecast', eventId, boardDate] });
setSelected(null);
setToast('슬롯 예약 완료 — 통행증(QR)을 발급하세요.');
},
onError: (e) => {
if (e instanceof ApiRequestError && e.code === 'CONFLICT') {
setToast('이미 예약된 슬롯과 겹칩니다. 다른 시간대를 선택하세요.');
return;
}
const msg = e instanceof ApiRequestError ? e.message : '예약에 실패했습니다.';
setToast(msg);
},
});
// 그리드 예약을 도크 인덱스 기준으로 변환(DockRowCells 계약).
const reservations = board?.reservations ?? [];
function pickCell(dock: number, hour: number) {
setSelected({ dock, hour });
setToast(`${DOCKS[dock]} · ${HOURS[hour]} 슬롯 선택`);
setToast(`${docks[dock]?.label ?? `도크 ${dock + 1}`} · ${hourList[hour]} 슬롯 선택`);
}
const set = <K extends keyof DockForm>(k: K, v: DockForm[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const set = <K extends keyof DockForm>(k: K, v: DockForm[K]) => setForm((f) => ({ ...f, [k]: v }));
const status: 'loading' | 'error' | 'success' = boardQ.isPending
? 'loading'
: boardQ.isError
? 'error'
: 'success';
return (
<div className="kx-page">
<header className="kx-dock__head">
<div>
<h1 className="kx-dock__title">/ </h1>
<p className="kx-dock__subtitle">· · 2026 </p>
</div>
<div className="kx-dock__head-badges">
<span className="kx-bi__degraded"> </span>
<p className="kx-dock__subtitle">· · </p>
</div>
</header>
{/* 필터 바 */}
<div className="kx-dock__filters">
<label className="kx-dock__field">
<span className="kx-dock__label"></span>
<select className="kx-select" aria-label="행사 선택" defaultValue="motor">
<option value="motor">2026 </option>
<option value="logistics">2026 </option>
</select>
</label>
<label className="kx-dock__field">
<span className="kx-dock__label"></span>
<select className="kx-select" aria-label="홀 선택" defaultValue="7">
<option value="7"> 7</option>
<option value="8"> 8</option>
<option value="9"> 9</option>
</select>
<span className="kx-dock__label"></span>
<input
type="date"
className="kx-select"
aria-label="예약 일자"
value={date || boardDate}
onChange={(e) => {
setDate(e.target.value);
setSelected(null);
}}
/>
</label>
<div className="kx-dock__filters-spacer" />
<div className="kx-seg" role="tablist" aria-label="반입/반출 전환">
@ -151,33 +178,39 @@ export function DockReservationPage() {
)}
{status === 'error' && (
<ErrorState message="도크 예약 현황을 불러오지 못했습니다." onRetry={retry} />
<ErrorState message="도크 예약 현황을 불러오지 못했습니다." onRetry={() => boardQ.refetch()} />
)}
{status === 'success' && (
<div className="kx-dock__split">
{/* 좌: 스케줄 그리드 */}
<section className="kx-card kx-dock__grid" aria-label={`${direction === 'in' ? '반입' : '반출'} 도크 슬롯 그리드`}>
{DOCKS.length === 0 ? (
<EmptyState title="예약 가능한 슬롯이 없습니다" description="행사·홀을 선택하세요." />
<section
className="kx-card kx-dock__grid"
aria-label={`${direction === 'in' ? '반입' : '반출'} 도크 슬롯 그리드`}
>
{docks.length === 0 ? (
<EmptyState title="예약 가능한 슬롯이 없습니다" description="행사 배정 홀에 도크가 없습니다." />
) : (
<>
<div className="kx-dock__grid-scroll">
<div className="kx-dock__ghead">
<div className="kx-dock__ghead-dock">DOCK</div>
<div className="kx-dock__times">
{HOURS.map((h) => (
{hourList.map((h) => (
<div key={h} className="kx-dock__time tnum">
{h}
</div>
))}
</div>
</div>
{DOCKS.map((dock, di) => (
<div key={dock} className="kx-dock__row">
<div className="kx-dock__row-label">{dock}</div>
{docks.map((dock, di) => (
<div key={dock.id} className="kx-dock__row">
<div className="kx-dock__row-label">{dock.label}</div>
<DockRowCells
dockIndex={di}
hours={hours}
dockLabel={dock.label}
hourList={hourList}
reservations={reservations}
selected={selected}
onPick={pickCell}
@ -207,7 +240,7 @@ export function DockReservationPage() {
<div className="kx-dock__form">
{selected && (
<p className="kx-dock__slot-note">
: {DOCKS[selected.dock]} · {HOURS[selected.hour]} ·{' '}
: {docks[selected.dock]?.label} · {hourList[selected.hour]} ·{' '}
{direction === 'in' ? '반입' : '반출'}
</p>
)}
@ -254,17 +287,20 @@ export function DockReservationPage() {
<button
type="button"
className="kx-dock__qr"
disabled={reserve.isPending}
onClick={() => {
if (!selected) {
setToast('먼저 슬롯을 선택하세요.');
return;
}
setToast('통행증(QR) 발급 완료 — 모바일 반입 화면 연동 (샘플)');
reserve.mutate();
}}
>
<span className="kx-dock__qr-main">
<span className="kx-dock__qr-title"> (QR)</span>
<span className="kx-dock__qr-sub">Generate Logistics Pass</span>
<span className="kx-dock__qr-title">
{reserve.isPending ? '예약 중…' : '예약 · 통행증 발급 (QR)'}
</span>
<span className="kx-dock__qr-sub">Reserve &amp; Generate Logistics Pass</span>
</span>
<span className="kx-dock__qr-glyph" aria-hidden="true">
<QrGlyph />
@ -273,15 +309,23 @@ export function DockReservationPage() {
</div>
</section>
<section className="kx-dock__ai" aria-label="철거일 대기열 예측">
<section className="kx-dock__ai" aria-label="대기열 예측">
<div className="kx-dock__ai-head">
<span className="kx-dock__ai-title">
<IconSpark size={18} />
<IconSpark size={18} /> {direction === 'out' ? '철거일' : '반입일'}
</span>
<AiLabel>AI </AiLabel>
<AiLabel>{forecastQ.data?.fallback ? 'AI 예측 · 폴백' : 'AI 예측'}</AiLabel>
</div>
<div className="kx-dock__chart" role="img" aria-label="시간대별 차량 밀집도 예측 — 오후 4시경 피크">
{QUEUE_FORECAST.map((h, i) => (
{forecastQ.isPending ? (
<Skeleton height={120} />
) : (
<>
<div
className="kx-dock__chart"
role="img"
aria-label={`시간대별 차량 밀집도 예측 — ${forecastQ.data?.peakLabel ?? ''}경 피크`}
>
{(forecastQ.data?.hourly ?? []).map((h, i) => (
<span
key={i}
className="kx-dock__bar"
@ -290,15 +334,20 @@ export function DockReservationPage() {
))}
</div>
<div className="kx-dock__chart-axis tnum">
<span>08:00</span>
<span>12:00</span>
<span>16:00</span>
<span>20:00</span>
<span>{hourList[0]}</span>
<span>{hourList[Math.floor(hours / 3)]}</span>
<span>{hourList[Math.floor((hours * 2) / 3)]}</span>
<span>{hourList[hours - 1]}</span>
</div>
<div className="kx-dock__ai-callout">
<strong> 4</strong> .
.
{forecastQ.data?.fallback
? '예약 데이터가 아직 없어 표준 패턴을 표시합니다. '
: ''}
<strong>{forecastQ.data?.peakLabel ?? '오후'}</strong>
. .
</div>
</>
)}
</section>
</aside>
</div>
@ -313,25 +362,31 @@ export function DockReservationPage() {
);
}
/** 단일 도크 행의 12개 시간대 셀 — 예약 블록은 span, 빈 칸은 선택 가능한 셀. */
/** 단일 도크 행의 시간대 셀 — 예약 블록은 span, 빈 칸은 선택 가능한 셀. */
function DockRowCells({
dockIndex,
hours,
dockLabel,
hourList,
reservations,
selected,
onPick,
}: {
dockIndex: number;
reservations: Reservation[];
hours: number;
dockLabel: string;
hourList: string[];
reservations: GridReservation[];
selected: { dock: number; hour: number } | null;
onPick: (dock: number, hour: number) => void;
}) {
const cells = useMemo(() => {
const rowRes = reservations
.filter((r) => r.dock === dockIndex)
.filter((r) => r.dockIndex === dockIndex)
.sort((a, b) => a.start - b.start);
const out: JSX.Element[] = [];
let col = 0;
while (col < 12) {
while (col < hours) {
const res = rowRes.find((r) => r.start === col);
if (res) {
out.push(
@ -355,7 +410,7 @@ function DockRowCells({
className={`kx-dock__cell ${isSel ? 'is-selected' : ''}`}
role="button"
tabIndex={0}
aria-label={`${DOCKS[dockIndex]} ${HOURS[hour]} 예약 가능`}
aria-label={`${dockLabel} ${hourList[hour]} 예약 가능`}
onClick={() => onPick(dockIndex, hour)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -369,7 +424,7 @@ function DockRowCells({
}
}
return out;
}, [dockIndex, reservations, selected, onPick]);
}, [dockIndex, hours, dockLabel, hourList, reservations, selected, onPick]);
return <div className="kx-dock__cells">{cells}</div>;
}

View File

@ -0,0 +1,65 @@
/*
* M8 / (SCR-25) API .
* (../../api/client) client.ts·endpoints.ts·types.ts .
* 정본: 백엔드 LogisticsController
* - GET /api/events/{eventId}/docks?direction=in|out&date=YYYY-MM-DD ApiResponse<DockBoard>
* - POST /api/events/{eventId}/dock-reservations ApiResponse<GridReservation> ( 409)
* - GET /api/events/{eventId}/dock-forecast?date=YYYY-MM-DD ApiResponse<Forecast>(fallback )
*/
import { api } from '../../api/client';
export interface Dock {
id: string;
dockNo: number;
label: string;
heavyPriority: boolean;
}
export interface GridReservation {
id: string;
dockIndex: number;
start: number;
span: number;
label: string;
tone: 'booked' | 'priority';
}
export interface DockBoard {
direction: 'in' | 'out';
date: string;
gridStartHour: number;
hours: number;
docks: Dock[];
reservations: GridReservation[];
}
export interface ReservationBody {
direction: 'in' | 'out';
date: string;
dockIndex: number;
startHour: number;
span?: number;
vehicleNo?: string;
weight?: string;
item?: string;
forklift?: boolean;
companyName?: string;
}
export interface Forecast {
hourly: number[];
fallback: boolean;
peakLabel: string;
}
export const logisticsApi = {
board: (eventId: string, direction: 'in' | 'out', date?: string) => {
const qs = new URLSearchParams({ direction });
if (date) qs.set('date', date);
return api.get<DockBoard>(`/api/events/${encodeURIComponent(eventId)}/docks?${qs.toString()}`);
},
reserve: (eventId: string, body: ReservationBody) =>
api.post<GridReservation>(`/api/events/${encodeURIComponent(eventId)}/dock-reservations`, body),
forecast: (eventId: string, date: string) =>
api.get<Forecast>(
`/api/events/${encodeURIComponent(eventId)}/dock-forecast?date=${encodeURIComponent(date)}`,
),
};

View File

@ -1,10 +1,13 @@
/*
* SCR-P2 (····FAQ) M12, .
* design.md §3B. + () + + + + CTA.
* 데이터: 공개 API .
* 데이터: GET /api/public/events/{eventId} (). ··FAQ는 ( API ).
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { publicApi, type PublicEvent } from './publicApi';
import { formatRange, errorMessage } from './publicFormat';
import {
IconCalendar,
IconPin,
@ -65,8 +68,36 @@ const SPEAKERS = [
];
export function PublicEventDetailPage() {
const { eventId } = useParams<{ eventId: string }>();
const [activeTab, setActiveTab] = useState('intro');
const [day, setDay] = useState(1);
const [event, setEvent] = useState<PublicEvent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!eventId) return;
let alive = true;
setLoading(true);
publicApi
.getEvent(eventId)
.then((e) => {
if (alive) {
setEvent(e);
setError('');
}
})
.catch((e) => alive && setError(errorMessage(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [eventId]);
const title = event?.name ?? (loading ? '행사 정보를 불러오는 중…' : '행사 상세');
const dateRange = event ? formatRange(event.startDate, event.endDate) : '일정 확인 중';
const place = event?.hallLabel ?? '홀 배정 예정';
const registerTo = eventId ? `/public/events/${encodeURIComponent(eventId)}/register` : '#program';
return (
<PublicShell active="events" cta="사전등록하기">
@ -84,32 +115,39 @@ export function PublicEventDetailPage() {
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">Global Manufacturing Expo</span>
<h1 className="kxp-hero__title"> 2026</h1>
<p className="kxp-hero__lead">AI .</p>
<span className="kxp-hero__eyebrow">KINTEX Exhibition</span>
<h1 className="kxp-hero__title">{title}</h1>
<p className="kxp-hero__lead">
{error ? error : '킨텍스에서 열리는 전시회 정보를 확인하고 사전등록하세요.'}
</p>
<div className="kxp-eventhero__facts kxp-hero__meta">
<span className="kxp-hero__metacard">
<IconCalendar width={18} height={18} />
<span>
<small></small>
<strong>2026.09.05 09.08</strong>
<strong>{dateRange}</strong>
</span>
</span>
<span className="kxp-hero__metacard">
<IconPin width={18} height={18} />
<span>
<small></small>
<strong> 2 7</strong>
<strong>{place}</strong>
</span>
</span>
</div>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#program">
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg" to={registerTo}>
</a>
<a className="kxp-btn kxp-btn--ghost kxp-btn--lg" href="#intro">
</a>
</Link>
{eventId && (
<Link
className="kxp-btn kxp-btn--ghost kxp-btn--lg"
to={`/public/events/${encodeURIComponent(eventId)}/floorplan`}
>
</Link>
)}
</div>
</div>
</div>
@ -147,9 +185,6 @@ export function PublicEventDetailPage() {
<div className="kxp-detail">
<div>
<h2 className="kxp-section__title"> </h2>
<span className="kxp-sample" style={{ marginBottom: 16, display: 'inline-flex' }}>
</span>
<p className="kxp-detail__lead">
2026 . AI, , IoT,
.
@ -158,8 +193,8 @@ export function PublicEventDetailPage() {
<div className="kxp-detail__stats">
<div className="kxp-statcard">
<IconFactory width={28} height={28} />
<strong>500+</strong>
<span> </span>
<strong>{event?.boothCount != null && event.boothCount > 0 ? `${event.boothCount}+` : '500+'}</strong>
<span> </span>
</div>
<div className="kxp-statcard">
<IconRobot width={28} height={28} />
@ -289,9 +324,9 @@ export function PublicEventDetailPage() {
</section>
{/* 사전등록 고정 CTA */}
<a className="kxp-btn kxp-btn--primary kxp-btn--lg kxp-fab" href="#program">
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg kxp-fab" to={registerTo}>
<IconChevronRight width={18} height={18} />
</a>
</Link>
</PublicShell>
);
}

View File

@ -1,10 +1,13 @@
/*
* SCR-P3 (M12·M2) .
* design.md §3B. : 검색/ + ( N1) / : 인터랙티브 + + .
* 데이터: 공개 API .
* 데이터: GET /api/public/events/{eventId}/floorplan (···).
*/
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { publicApi, type PublicFloorplan, type PublicFloorplanBooth } from './publicApi';
import { errorMessage } from './publicFormat';
import {
IconSearch,
IconChevronRight,
@ -16,61 +19,77 @@ import {
IconTarget,
} from './publicIcons';
type BoothStatus = 'available' | 'reserved' | 'public' | 'pending';
interface Booth {
code: string;
name: string;
category: string;
status: BoothStatus;
/** 그리드 배치 (col span, row span) */
span?: number;
}
const CATEGORIES = ['AI 솔루션', '로보틱스', 'IoT', '빅데이터'];
const BOOTHS: Booth[] = [
{ code: 'A-102', name: '(주)테크솔루션', category: 'AI 솔루션', status: 'available' },
{ code: 'A-103', name: '스마트비전', category: 'AI 솔루션', status: 'available' },
{ code: '공용부', name: '휴게 공간', category: '공용부', status: 'public', span: 2 },
{ code: 'A-104', name: '넥스트로직', category: 'AI 솔루션', status: 'available' },
{ code: 'C-110', name: '미래로보틱스', category: '로보틱스', status: 'available' },
{ code: 'A-105', name: '오토메이션랩', category: 'IoT', status: 'available' },
{ code: 'Stage', name: '메인 스테이지', category: '공용부', status: 'public', span: 3 },
{ code: 'A-106', name: '데이터브릿지', category: 'IoT', status: 'available' },
{ code: 'C-111', name: '로보다인', category: '로보틱스', status: 'available' },
{ code: 'B-201', name: '예약 부스', category: '예약', status: 'reserved' },
{ code: 'B-202', name: '예약 부스', category: '예약', status: 'reserved' },
{ code: 'B-205', name: '데이터랩스', category: '빅데이터', status: 'available' },
{ code: 'B-206', name: '인사이트AI', category: '빅데이터', status: 'available' },
{ code: 'Desk', name: '인포메이션', category: '공용부', status: 'public', span: 2 },
{ code: 'Hall', name: '입구 통로', category: '공용부', status: 'public', span: 4 },
{ code: 'C-120', name: '커넥트봇', category: '로보틱스', status: 'available' },
{ code: 'C-121', name: '글로벌커넥트', category: '로보틱스', status: 'pending' },
];
const LIST = BOOTHS.filter((b) => b.status !== 'public');
/** boothType 원문(assembled|independent|corner|island 등) → 표시 라벨. */
const TYPE_LABEL: Record<string, string> = {
assembled: '조립부스',
independent: '독립부스',
corner: '코너부스',
island: '아일랜드',
};
const typeLabel = (t: string | null | undefined) => (t ? TYPE_LABEL[t] ?? t : '부스');
export function PublicFloorplanPage() {
const { eventId } = useParams<{ eventId: string }>();
const [query, setQuery] = useState('');
const [cat, setCat] = useState<string | null>('AI 솔루션');
const [selected, setSelected] = useState<Booth | null>(null);
const [cat, setCat] = useState<string | null>(null);
const [selected, setSelected] = useState<PublicFloorplanBooth | null>(null);
const [scale, setScale] = useState(1);
const [hallId, setHallId] = useState<string>('all');
const [data, setData] = useState<PublicFloorplan | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!eventId) return;
let alive = true;
setLoading(true);
publicApi
.getFloorplan(eventId)
.then((d) => {
if (alive) {
setData(d);
setError('');
}
})
.catch((e) => alive && setError(errorMessage(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [eventId]);
const booths = useMemo(() => data?.booths ?? [], [data]);
const halls = data?.halls ?? [];
// 존/유형 필터 후보 = 데이터에 존재하는 boothType 집합
const categories = useMemo(
() => Array.from(new Set(booths.map((b) => typeLabel(b.boothType)))).slice(0, 8),
[booths],
);
const scoped = useMemo(
() => (hallId === 'all' ? booths : booths.filter((b) => b.hallLabel === hallId)),
[booths, hallId],
);
const filtered = useMemo(() => {
return LIST.filter((b) => {
return scoped.filter((b) => {
const q = query.trim();
const matchQ = !q || b.name.includes(q) || b.code.includes(q);
const matchC = !cat || b.category === cat;
const name = b.companyName ?? '';
const code = b.boothNo ?? '';
const matchQ = !q || name.includes(q) || code.includes(q);
const matchC = !cat || typeLabel(b.boothType) === cat;
return matchQ && matchC;
});
}, [query, cat]);
}, [scoped, query, cat]);
const boothClass = (b: Booth) => {
const boothKey = (b: PublicFloorplanBooth) => `${b.hallLabel ?? ''}#${b.boothNo ?? ''}`;
const boothClass = (b: PublicFloorplanBooth) => {
let c = 'kxp-booth';
if (b.status === 'reserved') c += ' kxp-booth--reserved';
if (b.status === 'public') c += ' kxp-booth--public';
if (selected?.code === b.code) c += ' is-selected';
if (selected && boothKey(selected) === boothKey(b)) c += ' is-selected';
return c;
};
@ -91,10 +110,11 @@ export function PublicFloorplanPage() {
aria-label="부스 검색"
/>
</div>
{categories.length > 0 && (
<div>
<p className="kxp-fp__label"> </p>
<p className="kxp-fp__label"> </p>
<div className="kxp-fp__chips">
{CATEGORIES.map((c) => (
{categories.map((c) => (
<button
key={c}
type="button"
@ -107,46 +127,62 @@ export function PublicFloorplanPage() {
))}
</div>
</div>
)}
{halls.length > 0 && (
<div>
<p className="kxp-fp__label"> </p>
<select className="kxp-select" aria-label="전시장 선택">
<option> 1 - Hall 1-A</option>
<option> 1 - Hall 1-B</option>
<option> 2 - Hall 6</option>
<select
className="kxp-select"
aria-label="전시장 선택"
value={hallId}
onChange={(e) => setHallId(e.target.value)}
>
<option value="all"> </option>
{halls.map((h) => (
<option key={h.hallId} value={h.label}>
{h.label}
</option>
))}
</select>
</div>
<span className="kxp-sample"> </span>
)}
</div>
<div className="kxp-fp__list" aria-label="부스 목록 (비시각 대안)">
{loading && <p className="kxp-empty" role="status"> </p>}
{!loading && error && (
<p className="kxp-empty" role="alert" style={{ color: 'var(--color-error)' }}>
{error}
</p>
)}
{!loading && !error && (
<>
<p className="kxp-fp__listhint"> ({filtered.length})</p>
{filtered.length === 0 && <p className="kxp-empty"> .</p>}
{filtered.map((b) => (
<button
key={b.code}
key={boothKey(b)}
type="button"
className={`kxp-fp__row${
b.status === 'reserved' ? ' kxp-fp__row--reserved' : ''
}${b.status === 'pending' ? ' kxp-fp__row--pending' : ''}${
selected?.code === b.code ? ' is-active' : ''
}`}
onClick={() => b.status !== 'pending' && setSelected(b)}
disabled={b.status === 'pending'}
}${selected && boothKey(selected) === boothKey(b) ? ' is-active' : ''}`}
onClick={() => setSelected(b)}
>
<span>
<span className="kxp-fp__row-code">{b.code}</span>
<span className="kxp-fp__row-name">{b.name}</span>
<span className="kxp-fp__row-code">{b.boothNo ?? '—'}</span>
<span className="kxp-fp__row-name">
{b.companyName ?? (b.status === 'reserved' ? '예약 부스' : '분양 가능')}
</span>
<span className="kxp-fp__row-cat">
{b.status === 'pending' ? '준비 중' : b.category}
{typeLabel(b.boothType)}
{b.hallLabel ? ` · ${b.hallLabel}` : ''}
</span>
</span>
{b.status === 'pending' ? (
<span className="kxp-tag"></span>
) : (
<IconChevronRight width={18} height={18} />
)}
</button>
))}
</>
)}
</div>
</aside>
@ -159,20 +195,23 @@ export function PublicFloorplanPage() {
role="group"
aria-label="Hall 1-A 부스 배치"
>
{BOOTHS.map((b, i) => (
{scoped.map((b, i) => (
<button
key={`${b.code}-${i}`}
key={`${boothKey(b)}-${i}`}
type="button"
className={boothClass(b)}
style={b.span ? { gridColumn: `span ${b.span}` } : undefined}
onClick={() => b.status !== 'public' && b.status !== 'pending' && setSelected(b)}
disabled={b.status === 'public' || b.status === 'pending'}
aria-label={`부스 ${b.code} ${b.name}`}
onClick={() => setSelected(b)}
aria-label={`부스 ${b.boothNo ?? ''} ${b.companyName ?? ''}`}
>
{b.code}
{b.boothNo ?? '—'}
{b.status === 'reserved' && <span className="kxp-booth__badge">R</span>}
</button>
))}
{!loading && scoped.length === 0 && (
<p className="kxp-empty" style={{ gridColumn: '1 / -1' }}>
{error ? '도면을 불러오지 못했습니다.' : '표시할 부스가 없습니다.'}
</p>
)}
</div>
</div>
@ -182,11 +221,11 @@ export function PublicFloorplanPage() {
className="kxp-fp__pop"
style={{ left: 24, top: 24 }}
role="dialog"
aria-label={`${selected.name} 정보`}
aria-label={`${selected.companyName ?? selected.boothNo ?? '부스'} 정보`}
>
<div className="kxp-fp__pop-head">
<div className="kxp-fp__pop-top">
<span className="kxp-chip">{selected.code}</span>
<span className="kxp-chip">{selected.boothNo ?? '—'}</span>
<button
type="button"
className="kxp-fp__pop-close"
@ -196,8 +235,13 @@ export function PublicFloorplanPage() {
<IconClose width={18} height={18} />
</button>
</div>
<h5 className="kxp-fp__pop-name">{selected.name}</h5>
<p className="kxp-fp__pop-cat"> · {selected.category}</p>
<h5 className="kxp-fp__pop-name">
{selected.companyName ?? (selected.status === 'reserved' ? '예약 부스' : '분양 가능')}
</h5>
<p className="kxp-fp__pop-cat">
{typeLabel(selected.boothType)}
{selected.hallLabel ? ` · ${selected.hallLabel}` : ''}
</p>
</div>
<div className="kxp-fp__pop-foot">
<a className="kxp-fp__pop-link" href="#top">

View File

@ -1,9 +1,13 @@
/*
* SCR-P1 (M12) .
* design.md §3B. + / + (GTX-A) .
* 데이터: 공개 API (_workspace/port_public.md ).
* 데이터: GET /api/public/events ( , ).
*/
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { publicApi, type PublicEvent } from './publicApi';
import { deriveStatus, dday, formatRange, errorMessage } from './publicFormat';
import {
IconSparkles,
IconCalendar,
@ -16,55 +20,44 @@ import {
IconMap,
} from './publicIcons';
interface SampleEvent {
id: string;
title: string;
dates: string;
hall: string;
tags: string[];
dday: number;
ai?: boolean;
accent: 'primary' | 'ai';
}
const SAMPLE_EVENTS: SampleEvent[] = [
{
id: 'smart-factory-2026',
title: '스마트팩토리 & AI 엑스포 2026',
dates: '2026.08.18 08.21',
hall: '제1전시장 1~5홀',
tags: ['EXHIBITION', 'TECH'],
dday: 15,
ai: true,
accent: 'primary',
},
{
id: 'comic-world-summer',
title: '코믹월드 썸머 2026',
dates: '2026.07.24 07.26',
hall: '제2전시장 7, 8홀',
tags: ['CULTURE', 'FESTIVAL'],
dday: 3,
accent: 'ai',
},
{
id: 'boat-show-2026',
title: '경기 국제 보트쇼 2026',
dates: '2026.04.11 04.14',
hall: '제1전시장 3, 4, 5홀',
tags: ['EXHIBITION', 'LEISURE'],
dday: 45,
accent: 'primary',
},
];
function ddayClass(d: number) {
if (d <= 3) return 'kxp-dday kxp-dday--soon';
if (d <= 7) return 'kxp-dday kxp-dday--warn';
return 'kxp-dday';
}
/** 진행중·예정 우선 정렬 후 상위 N건만 홈에 노출. */
function pickFeatured(events: PublicEvent[], max = 6): PublicEvent[] {
const rank = (e: PublicEvent) => {
const s = deriveStatus(e.startDate, e.endDate);
return s === 'ongoing' ? 0 : s === 'upcoming' ? 1 : 2;
};
return [...events].sort((a, b) => rank(a) - rank(b)).slice(0, max);
}
export function PublicHomePage() {
const [events, setEvents] = useState<PublicEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
setLoading(true);
publicApi
.listEvents()
.then((list) => {
if (alive) {
setEvents(pickFeatured(list));
setError('');
}
})
.catch((e) => alive && setError(errorMessage(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, []);
return (
<PublicShell active="events">
{/* 히어로 */}
@ -98,9 +91,6 @@ export function PublicHomePage() {
KINTEX에서
.
</p>
<span className="kxp-sample" title="공개 카탈로그 API 연동 전 임시 데이터">
</span>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
@ -126,56 +116,86 @@ export function PublicHomePage() {
</a>
</div>
{loading && (
<p className="kxp-empty" role="status">
</p>
)}
{!loading && error && (
<p className="kxp-empty" role="alert" style={{ color: 'var(--color-error)' }}>
{error}
</p>
)}
{!loading && !error && events.length === 0 && (
<p className="kxp-empty"> .</p>
)}
{!loading && !error && events.length > 0 && (
<div className="kxp-eventgrid">
{SAMPLE_EVENTS.map((ev) => (
{events.map((ev, i) => {
const status = deriveStatus(ev.startDate, ev.endDate);
const d = dday(ev.startDate);
const accentAi = i % 3 === 1;
return (
<article
key={ev.id}
className="kxp-ecard"
style={ev.accent === 'ai' ? { borderLeftColor: 'var(--color-ai-accent)' } : undefined}
style={accentAi ? { borderLeftColor: 'var(--color-ai-accent)' } : undefined}
>
<div className="kxp-ecard__media">
<div
style={{
width: '100%',
height: '100%',
background:
ev.accent === 'ai'
background: accentAi
? 'linear-gradient(135deg, #6d4aff 0%, #1f29fc 100%)'
: 'linear-gradient(135deg, #0066b3 0%, #004c86 100%)',
}}
aria-hidden
/>
{ev.ai && (
{accentAi && (
<span className="kxp-ecard__topright kxp-chip kxp-chip--ai">
<IconSparkles width={13} height={13} /> AI
</span>
)}
<span className={`kxp-ecard__botleft ${ddayClass(ev.dday)}`}>D-{ev.dday}</span>
{status === 'ongoing' ? (
<span className="kxp-ecard__botleft kxp-dday kxp-dday--soon"></span>
) : status === 'ended' ? (
<span className="kxp-ecard__botleft kxp-dday"></span>
) : (
<span className={`kxp-ecard__botleft ${ddayClass(d)}`}>
{d === 0 ? 'D-DAY' : `D-${d}`}
</span>
)}
</div>
<div className="kxp-ecard__body">
<div className="kxp-ecard__tags">
{ev.tags.map((t) => (
<span key={t} className="kxp-tag">
{t}
</span>
))}
<span className="kxp-tag">EXHIBITION</span>
{ev.boothCount != null && ev.boothCount > 0 && (
<span className="kxp-tag"> {ev.boothCount}</span>
)}
</div>
<h3 className="kxp-ecard__title">{ev.title}</h3>
<h3 className="kxp-ecard__title">{ev.name}</h3>
<div className="kxp-ecard__meta">
<span>
<IconCalendar width={18} height={18} /> {ev.dates}
<IconCalendar width={18} height={18} /> {formatRange(ev.startDate, ev.endDate)}
</span>
<span>
<IconPin width={18} height={18} /> {ev.hall}
<IconPin width={18} height={18} /> {ev.hallLabel ?? '홀 배정 예정'}
</span>
</div>
<a className="kxp-btn kxp-btn--primary kxp-btn--block" href="#events">
<Link
className="kxp-btn kxp-btn--primary kxp-btn--block"
to={`/public/events/${encodeURIComponent(ev.id)}/register`}
>
</a>
</Link>
</div>
</article>
))}
);
})}
</div>
)}
</div>
</section>

View File

@ -1,10 +1,12 @@
/*
* SCR-P6 / (M12) .
* design.md §3B. ( ·) + + + .
* "준비 중" . () .
* 제출: POST /api/public/inquiries ( ). () .
*/
import { useState } from 'react';
import { PublicShell } from './PublicShell';
import { publicApi } from './publicApi';
import { errorMessage } from './publicFormat';
import { IconPhone, IconMail, IconPin, IconSend, IconArrowRight, IconCheckCircle } from './publicIcons';
const BOOTH_TYPES = [
@ -29,17 +31,56 @@ const BOOTH_TYPES = [
];
export function PublicInquiryPage() {
const [company, setCompany] = useState('');
const [name, setName] = useState('');
const [contact, setContact] = useState('');
const [scale, setScale] = useState('');
const [hall, setHall] = useState('');
const [message, setMessage] = useState('');
const [agree, setAgree] = useState(false);
const [sent, setSent] = useState(false);
const [receiptNo, setReceiptNo] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const submit = () => {
const submit = async () => {
setError('');
if (!agree) {
setError('개인정보 수집 및 이용에 동의해 주세요.');
return;
}
if (!name.trim()) {
setError('담당자 이름을 입력해 주세요.');
return;
}
if (!contact.trim()) {
setError('연락 가능한 이메일 또는 전화번호를 입력해 주세요.');
return;
}
if (!message.trim()) {
setError('문의 내용을 입력해 주세요.');
return;
}
const isEmail = contact.includes('@');
setSubmitting(true);
try {
const r = await publicApi.submitInquiry({
company: company.trim() || undefined,
contactName: name.trim(),
email: isEmail ? contact.trim() : undefined,
phone: isEmail ? undefined : contact.trim(),
scale: scale || undefined,
hallPref: hall || undefined,
message: message.trim(),
agreePrivacy: agree,
});
setReceiptNo(r.receiptNo);
setSent(true);
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
};
return (
@ -74,7 +115,6 @@ export function PublicInquiryPage() {
<div className="kxp-wrap">
<div className="kxp-section__head" style={{ justifyContent: 'center', flexDirection: 'column', textAlign: 'center' }}>
<h2 className="kxp-section__title"> </h2>
<span className="kxp-sample"> </span>
</div>
<div className="kxp-boothtypes">
{BOOTH_TYPES.map((b) => (
@ -151,27 +191,50 @@ export function PublicInquiryPage() {
<div className="kxp-formcard">
{sent && (
<div className="kxp-alert kxp-alert--success" role="status">
<IconCheckCircle width={18} height={18} /> . ( )
<IconCheckCircle width={18} height={18} /> . {receiptNo}
</div>
)}
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="iq-company"></label>
<input id="iq-company" className="kxp-input" placeholder="회사명을 입력하세요" />
<input
id="iq-company"
className="kxp-input"
placeholder="회사명을 입력하세요"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</div>
<div className="kxp-field">
<label htmlFor="iq-name"></label>
<input id="iq-name" className="kxp-input" placeholder="성함을 입력하세요" />
<input
id="iq-name"
className="kxp-input"
placeholder="성함을 입력하세요"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="kxp-field">
<label htmlFor="iq-contact"> ( )</label>
<input id="iq-contact" className="kxp-input" placeholder="연락 가능한 정보를 입력하세요" />
<input
id="iq-contact"
className="kxp-input"
placeholder="연락 가능한 정보를 입력하세요"
value={contact}
onChange={(e) => setContact(e.target.value)}
/>
</div>
<div className="kxp-formgrid kxp-formgrid--2">
<div className="kxp-field">
<label htmlFor="iq-scale"> </label>
<select id="iq-scale" className="kxp-select">
<select
id="iq-scale"
className="kxp-select"
value={scale}
onChange={(e) => setScale(e.target.value)}
>
<option value=""></option>
<option>1 (9sqm)</option>
<option>2-4 (18-36sqm)</option>
@ -181,7 +244,12 @@ export function PublicInquiryPage() {
</div>
<div className="kxp-field">
<label htmlFor="iq-hall"> </label>
<select id="iq-hall" className="kxp-select">
<select
id="iq-hall"
className="kxp-select"
value={hall}
onChange={(e) => setHall(e.target.value)}
>
<option value=""> </option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i}>Hall {i + 1}</option>
@ -195,6 +263,8 @@ export function PublicInquiryPage() {
id="iq-msg"
className="kxp-textarea"
placeholder="참가 목적이나 특별 요청사항이 있다면 남겨주세요."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<label className="kxp-check">
@ -208,8 +278,13 @@ export function PublicInquiryPage() {
{error}
</p>
)}
<button type="button" className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg" onClick={submit}>
<IconSend width={18} height={18} />
<button
type="button"
className="kxp-btn kxp-btn--primary kxp-btn--block kxp-btn--lg"
onClick={submit}
disabled={submitting}
>
{submitting ? '전송 중…' : '문의 보내기'} <IconSend width={18} height={18} />
</button>
</div>
</div>

View File

@ -1,9 +1,12 @@
/*
* SCR-P5 (M17) .
* design.md §3B. + ( ) + + AI () + CTA.
* 데이터: 공개 API .
* 데이터: GET /api/public/microsites/{exhibitorId} (cms ). shape / .
*/
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { publicApi, type PublicMicrosite } from './publicApi';
import { IconPin, IconMap, IconArrowRight } from './publicIcons';
const PRODUCTS = [
@ -30,12 +33,46 @@ const PRODUCTS = [
const BRAND = '(주)한빛로보틱스';
export function PublicMicrositePage() {
const { exhibitorId } = useParams<{ exhibitorId: string }>();
const [site, setSite] = useState<PublicMicrosite | null>(null);
useEffect(() => {
if (!exhibitorId) return;
let alive = true;
publicApi
.getMicrosite(exhibitorId)
.then((s) => alive && setSite(s))
.catch(() => {
// cms 마이크로사이트 API 미배포/미공개 — 안내 콘텐츠로 폴백(차단 없음).
if (alive) setSite(null);
});
return () => {
alive = false;
};
}, [exhibitorId]);
const brand = site?.brandName ?? BRAND;
const tagline = site?.tagline ?? '미래를 움직이는 지능형 로보틱스 솔루션';
const boothLoc =
site?.boothNo || site?.hallLabel
? `부스 ${site?.boothNo ?? '미정'}${site?.hallLabel ? ` · ${site.hallLabel}` : ''}`
: '부스 A-102 · 홀7';
const products =
site?.products && site.products.length > 0
? site.products.map((p, i) => ({
tag: p.tag ?? '제품',
title: p.title ?? '제품',
desc: p.description ?? '',
accent: ['#0066b3', '#6d4aff', '#0e8a5f'][i % 3],
}))
: PRODUCTS;
return (
<PublicShell active="exhibit" brand={BRAND} cta="문의하기" search={false}>
<PublicShell active="exhibit" brand={brand} cta="문의하기" search={false}>
{/* 브랜드 히어로 */}
<section
className="kxp-hero"
aria-label={`${BRAND} 소개`}
aria-label={`${brand} 소개`}
style={{ backgroundColor: 'var(--color-canvas-bg)' }}
>
<div
@ -46,9 +83,9 @@ export function PublicMicrositePage() {
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">Global Robotics Leader</span>
<h1 className="kxp-hero__title">{BRAND}</h1>
<p className="kxp-hero__lead"> </p>
<span className="kxp-hero__eyebrow">KINTEX Exhibitor</span>
<h1 className="kxp-hero__title">{brand}</h1>
<p className="kxp-hero__lead">{tagline}</p>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#meeting">
@ -72,7 +109,7 @@ export function PublicMicrositePage() {
<div style={{ fontSize: 'var(--fs-caption)', color: 'var(--color-neutral-500)' }}>
KINTEX
</div>
<div className="kxp-ms-booth__loc"> A-102 · 7</div>
<div className="kxp-ms-booth__loc">{boothLoc}</div>
</div>
</div>
<a className="kxp-btn kxp-btn--outline" href="#top">
@ -89,10 +126,9 @@ export function PublicMicrositePage() {
<h2 className="kxp-section__title"> </h2>
<p className="kxp-section__sub"> .</p>
</div>
<span className="kxp-sample"> </span>
</div>
<div className="kxp-products">
{PRODUCTS.map((p) => (
{products.map((p) => (
<article className="kxp-product" key={p.title} style={{ borderLeftColor: p.accent }}>
<div
className="kxp-product__media"

View File

@ -1,10 +1,14 @@
/*
* SCR-P4 (M10) .
* design.md §3B. 4 (). "준비 중".
* () . QR .
* design.md §3B. 4 ().
* POST /api/public/events/{eventId}/register (visitor ) 3 .
* () . QR( ).
*/
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { publicApi, type RegistrationReceipt } from './publicApi';
import { errorMessage } from './publicFormat';
import {
IconPerson,
IconBuyer,
@ -33,21 +37,65 @@ const TYPE_LABEL: Record<string, string> = {
};
export function PublicRegistrationPage() {
const { eventId } = useParams<{ eventId: string }>();
const [step, setStep] = useState(0); // 0..3
const [type, setType] = useState('general');
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [org, setOrg] = useState('');
const [interests, setInterests] = useState<string[]>([]);
const [agreePrivacy, setAgreePrivacy] = useState(false);
const [agreeMkt, setAgreeMkt] = useState(false);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [receipt, setReceipt] = useState<RegistrationReceipt | null>(null);
const toggleInterest = (v: string) =>
setInterests((prev) => (prev.includes(v) ? prev.filter((i) => i !== v) : [...prev, v]));
const submit = async () => {
setError('');
if (!agreePrivacy) {
setError('개인정보 수집·이용 동의(필수)에 체크해 주세요.');
return;
}
if (!name.trim()) {
setError('이름을 입력해 주세요.');
return;
}
if (!eventId) {
setError('행사 정보를 확인할 수 없습니다.');
return;
}
setSubmitting(true);
try {
const r = await publicApi.register(eventId, {
name: name.trim(),
phone: phone.trim(),
email: email.trim(),
visitorType: type,
agreePrivacy,
agreeMarketing: agreeMkt,
});
setReceipt(r);
setStep(3);
} catch (e) {
// 백엔드(visitor 트랙) 미배포 시에도 UX 차단하지 않음 — 오류 메시지 노출.
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
};
const next = () => {
setError('');
if (step === 2 && !agreePrivacy) {
setError('개인정보 수집·이용 동의(필수)에 체크해 주세요.');
if (step === 2) {
void submit();
return;
}
if (step === 1 && !name.trim()) {
setError('이름을 입력해 주세요.');
return;
}
setStep((s) => Math.min(3, s + 1));
@ -124,15 +172,35 @@ export function PublicRegistrationPage() {
</div>
<div className="kxp-field">
<label htmlFor="reg-tel"> (Mobile)</label>
<input id="reg-tel" className="kxp-input" type="tel" placeholder="010-0000-0000" />
<input
id="reg-tel"
className="kxp-input"
type="tel"
placeholder="010-0000-0000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className="kxp-field kxp-formgrid__full">
<label htmlFor="reg-email"></label>
<input id="reg-email" className="kxp-input" type="email" placeholder="example@domain.com" />
<input
id="reg-email"
className="kxp-input"
type="email"
placeholder="example@domain.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="kxp-field kxp-formgrid__full">
<label htmlFor="reg-org"> (/)</label>
<input id="reg-org" className="kxp-input" placeholder="회사명 또는 학교명" />
<input
id="reg-org"
className="kxp-input"
placeholder="회사명 또는 학교명"
value={org}
onChange={(e) => setOrg(e.target.value)}
/>
</div>
</div>
</div>
@ -200,7 +268,7 @@ export function PublicRegistrationPage() {
</div>
<h2 className="kxp-regcard__title"> </h2>
<p className="kxp-regcard__sub">
<b> </b>. .
{name || '관람객'} . .
</p>
<div className="kxp-badge" aria-label="모바일 배지 미리보기">
@ -213,8 +281,8 @@ export function PublicRegistrationPage() {
<div className="kxp-badge__qrbox">
<IconQr />
</div>
<div className="kxp-badge__id">Registration ID</div>
<div className="kxp-badge__idval">KTX-2026-0000-DEMO</div>
<div className="kxp-badge__id">Badge Code</div>
<div className="kxp-badge__idval">{receipt?.badgeCode ?? receipt?.registrationId ?? '—'}</div>
</div>
</div>
@ -222,10 +290,7 @@ export function PublicRegistrationPage() {
<span style={{ display: 'inline-flex', gap: 8, alignItems: 'center' }}>
<IconBell width={18} height={18} /> QR을 .
</span>
<span> .</span>
<span className="kxp-sample" style={{ alignSelf: 'flex-start' }}>
·
</span>
{receipt?.registrationId && <span>: {receipt.registrationId}</span>}
</div>
</div>
)}
@ -238,18 +303,33 @@ export function PublicRegistrationPage() {
</button>
)}
<button type="button" className="kxp-btn kxp-btn--primary" onClick={next}>
{step === 2 ? '등록하기' : '다음'} <IconChevronRight width={18} height={18} />
<button
type="button"
className="kxp-btn kxp-btn--primary"
onClick={next}
disabled={submitting}
>
{step === 2 ? (submitting ? '등록 중…' : '등록하기') : '다음'}{' '}
<IconChevronRight width={18} height={18} />
</button>
</div>
) : (
<div className="kxp-regnav">
<a className="kxp-btn kxp-btn--outline" href="#top">
<Link className="kxp-btn kxp-btn--outline" to="/public">
</a>
<a className="kxp-btn kxp-btn--primary" href="#top">
<IconArrowRight width={18} height={18} />
</a>
</Link>
{eventId ? (
<Link
className="kxp-btn kxp-btn--primary"
to={`/public/events/${encodeURIComponent(eventId)}/floorplan`}
>
<IconArrowRight width={18} height={18} />
</Link>
) : (
<Link className="kxp-btn kxp-btn--primary" to="/public">
<IconArrowRight width={18} height={18} />
</Link>
)}
</div>
)}
</div>

View File

@ -0,0 +1,124 @@
/*
* (M12) API .
* client anonymous (client.ts ). ApiResponse.data .
* 근거: kintex-backend-dev com.zioinfo.kintex.publicsite + _workspace/port_public.md G1~G6.
*/
import { api } from '../../api/client';
// ── 공개 카탈로그 shape (백엔드 PublicEventDto) ──────────────────────────────
export interface PublicEvent {
id: string;
name: string;
startDate: string | null;
endDate: string | null;
status: string | null;
hallLabel: string | null;
boothCount: number | null;
}
export interface PublicFloorplanHall {
hallId: string;
label: string;
center: number | null;
primary: boolean;
}
export interface PublicFloorplanBooth {
boothNo: string | null;
companyName: string | null;
boothType: string | null;
status: 'available' | 'reserved' | string;
cx: number | null;
cy: number | null;
hallLabel: string | null;
}
export interface PublicFloorplan {
eventId: string;
eventName: string;
halls: PublicFloorplanHall[];
booths: PublicFloorplanBooth[];
}
// ── 문의 접수 (백엔드 InquiryRequest / InquiryReceiptDto) ────────────────────
export interface InquiryPayload {
inquiryType?: string;
company?: string;
contactName?: string;
email?: string;
phone?: string;
scale?: string;
hallPref?: string;
message?: string;
agreePrivacy: boolean;
agreeMarketing?: boolean;
}
export interface InquiryReceipt {
receiptNo: string;
status: string;
}
// ── 관람객 사전등록 (visitor 팀 구현 중 — 계약만 전제) ──────────────────────
export interface RegistrationPayload {
name: string;
phone: string;
email: string;
visitorType: string;
agreePrivacy: boolean;
agreeMarketing?: boolean;
}
export interface RegistrationReceipt {
registrationId: string;
badgeCode: string;
}
// ── 참가업체 마이크로사이트 (cms 팀 계약 — 관용적 옵셔널) ────────────────────
export interface PublicMicrosite {
exhibitorId?: string;
brandName?: string;
tagline?: string;
boothNo?: string;
hallLabel?: string;
products?: Array<{ tag?: string; title?: string; description?: string }>;
renderShots?: Array<{ label?: string; imageUrl?: string }>;
[key: string]: unknown;
}
const opt = { anonymous: true } as const;
function qs(params: Record<string, string | undefined>): string {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v != null && v !== '') sp.set(k, v);
});
const s = sp.toString();
return s ? `?${s}` : '';
}
export const publicApi = {
listEvents: (year?: string, q?: string) =>
api.get<PublicEvent[]>(`/api/public/events${qs({ year, q })}`, opt),
getEvent: (eventId: string) =>
api.get<PublicEvent>(`/api/public/events/${encodeURIComponent(eventId)}`, opt),
getFloorplan: (eventId: string) =>
api.get<PublicFloorplan>(`/api/public/events/${encodeURIComponent(eventId)}/floorplan`, opt),
submitInquiry: (payload: InquiryPayload) =>
api.post<InquiryReceipt>('/api/public/inquiries', payload, opt),
/** visitor 팀 계약 — 백엔드 미배포 시 에러(3상태 토스트로 처리). */
register: (eventId: string, payload: RegistrationPayload) =>
api.post<RegistrationReceipt>(
`/api/public/events/${encodeURIComponent(eventId)}/register`,
payload,
opt,
),
/** cms 팀 계약 — shape 확정 전 관용 처리. */
getMicrosite: (exhibitorId: string) =>
api.get<PublicMicrosite>(`/api/public/microsites/${encodeURIComponent(exhibitorId)}`, opt),
};

View File

@ -0,0 +1,57 @@
/*
* ·D-day· ( ).
* status event.status DB (EventCatalogMapper ).
*/
export type PublicStatus = 'ongoing' | 'upcoming' | 'ended';
function parse(d: string | null | undefined): Date | null {
if (!d) return null;
const dt = new Date(`${d}T00:00:00`);
return Number.isNaN(dt.getTime()) ? null : dt;
}
function today0(): Date {
const t = new Date();
return new Date(t.getFullYear(), t.getMonth(), t.getDate());
}
/** 날짜 기준 상태 재계산 — ended=종료<오늘, ongoing=시작≤오늘≤종료, upcoming=시작>오늘. */
export function deriveStatus(startDate: string | null, endDate: string | null): PublicStatus {
const s = parse(startDate);
const e = parse(endDate) ?? s;
const now = today0();
if (e && e < now) return 'ended';
if (s && s <= now && (e ?? s) >= now) return 'ongoing';
return 'upcoming';
}
/** 시작일까지 남은 일수(D-day). 과거면 0 이하. */
export function dday(startDate: string | null): number {
const s = parse(startDate);
if (!s) return 0;
const ms = s.getTime() - today0().getTime();
return Math.round(ms / 86_400_000);
}
/** "2026.08.18 08.21"(같은 해면 뒤는 월·일만). */
export function formatRange(startDate: string | null, endDate: string | null): string {
const s = parse(startDate);
const e = parse(endDate);
const pad = (n: number) => String(n).padStart(2, '0');
if (!s) return '일정 미정';
const sStr = `${s.getFullYear()}.${pad(s.getMonth() + 1)}.${pad(s.getDate())}`;
if (!e) return sStr;
const sameYear = e.getFullYear() === s.getFullYear();
const eStr = sameYear
? `${pad(e.getMonth() + 1)}.${pad(e.getDate())}`
: `${e.getFullYear()}.${pad(e.getMonth() + 1)}.${pad(e.getDate())}`;
return `${sStr} ${eStr}`;
}
/** 사용자 노출용 오류 메시지(민감정보 없음). */
export function errorMessage(e: unknown): string {
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message: unknown }).message === 'string') {
return (e as { message: string }).message;
}
return '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.';
}

View File

@ -1,24 +1,53 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconDownload, IconSpark } from '../../components/ui/icons';
import { LEAD_KPIS, LEADS, type Lead } from './sampleVisitor';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { useParams } from 'react-router-dom';
import { LEADS } from './sampleVisitor';
import { visitorApi, type LeadItemDto } from './visitorApi';
import './visitor.css';
/*
* SCR-32 ·AI (M10). Stitch scr_32_lead_scoring .
* M10 API , sampleVisitor.LEADS .
* 불변: 리드 ·· (PII · R10).
* 경로: GET /api/events/{eventId}/leads?boothId ( + AI ).
* KPI (= 80+, =AI ). 폴백: 오프라인 sampleVisitor.LEADS.
* 불변: 리드 ·· (PII · R10).
*/
export function LeadScoringPage() {
const eventId = useResolvedEventId();
const { boothId } = useParams();
const [hotOnly, setHotOnly] = useState(false);
const [selectedId, setSelectedId] = useState<string>(LEADS[0]?.id ?? '');
const [selectedId, setSelectedId] = useState<string>('');
const q = useQuery({
queryKey: ['leads', eventId, boothId ?? null],
queryFn: () => visitorApi.leads(eventId as string, boothId ?? null, 0, 100),
enabled: !!eventId,
retry: false,
});
const degraded = isDegradable(q.error);
const leads: LeadItemDto[] = q.data?.items ?? (degraded ? SAMPLE_LEADS : []);
const total = q.data?.total ?? (degraded ? SAMPLE_LEADS.length : 0);
const hardError = q.isError && !degraded;
const rows = useMemo(
() => (hotOnly ? LEADS.filter((l) => l.score >= 80) : LEADS),
[hotOnly],
() => (hotOnly ? leads.filter((l) => l.score >= 80) : leads),
[hotOnly, leads],
);
const selected = LEADS.find((l) => l.id === selectedId) ?? rows[0] ?? LEADS[0];
const selected = leads.find((l) => l.id === selectedId) ?? rows[0] ?? leads[0] ?? null;
const hotCount = leads.filter((l) => l.score >= 80).length;
const kpis = [
{ label: '총 리드', value: total.toLocaleString(), sub: undefined as string | undefined, ai: false },
{ label: '핫리드', value: String(hotCount), sub: 'AI 스코어 80+', ai: false },
{ label: '팔로업 대기', value: String(Math.max(total - hotCount, 0)), sub: undefined, ai: false },
{ label: '전환 추정', value: String(Math.round(hotCount * 0.35)), sub: 'AI 파생', ai: true },
];
return (
<div className="kx-vis">
@ -28,15 +57,34 @@ export function LeadScoringPage() {
<p className="kx-vis__subtitle"> · · </p>
</div>
<div className="kx-vis__head-actions">
<span className="kx-sample" title="M10 리드 모듈 미연동 — 시연 데이터"> </span>
{degraded && (
<span className="kx-vis__degraded" title="리드 API 미연결 — 시연 데이터"> </span>
)}
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>
CSV
</Button>
</div>
</header>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="리드를 조회할 행사가 지정되지 않았습니다." />
)}
{eventId && q.isLoading && !degraded && (
<div style={{ display: 'grid', gap: 16 }}>
<Skeleton height={92} radius={12} />
<Skeleton height={420} radius={12} />
</div>
)}
{hardError && (
<ErrorState message="리드를 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{eventId && (!q.isLoading || degraded) && !hardError && (
<>
<section className="kx-vis__kpis kx-vis__kpis--4" aria-label="리드 핵심 지표">
{LEAD_KPIS.map((k) => (
{kpis.map((k) => (
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
<span className="kx-kpi__label">
{k.label}
@ -97,30 +145,39 @@ export function LeadScoringPage() {
}}
>
<td className="kx-vis__lead-name">{l.nameMasked}</td>
<td>{l.company}</td>
<td>{l.product}</td>
<td>{l.company ?? '-'}</td>
<td>{l.product ?? '-'}</td>
<td><Stars value={l.interest} /></td>
<td><ScoreGauge score={l.score} /></td>
<td className="tnum">{l.collectedAt}</td>
<td className="tnum">{l.collectedAt ?? '-'}</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={6}>
<EmptyState title="수집된 리드가 없습니다" description="부스 배지 QR 스캔이 수집되면 표시됩니다." />
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="kx-vis__table-foot">
<span className="kx-vis__count"> 342 {rows.length} </span>
<span className="kx-vis__count"> {total.toLocaleString()} {rows.length} </span>
</div>
</section>
{/* 리드 상세·팔로업 */}
{selected && <LeadDetail lead={selected} />}
</div>
</>
)}
</div>
);
}
function LeadDetail({ lead }: { lead: Lead }) {
function LeadDetail({ lead }: { lead: LeadItemDto }) {
const hot = lead.score >= 80;
return (
<aside className="kx-vis__lead-detail" aria-label="리드 상세">
@ -134,9 +191,9 @@ function LeadDetail({ lead }: { lead: Lead }) {
<h2>{lead.nameMasked}</h2>
{hot && <span className="kx-vis__hot">HOT</span>}
</div>
<p className="kx-vis__lead-role">{lead.role} · {lead.company}</p>
<p className="kx-vis__lead-role">{lead.role ?? '-'} · {lead.company ?? '-'}</p>
<p className="kx-vis__lead-contact">
{lead.phoneMasked} · {lead.emailMasked}
{lead.phoneMasked ?? '-'} · {lead.emailMasked ?? '-'}
</p>
</div>
</div>
@ -152,7 +209,9 @@ function LeadDetail({ lead }: { lead: Lead }) {
<ul className="kx-vis__ai-reasons">
{lead.aiReasons.map((r, i) => (
<li key={i}>
<span className="kx-vis__ai-check" aria-hidden="true"></span>
<span className="kx-vis__ai-check" aria-hidden="true">
<CheckGlyph />
</span>
{r}
</li>
))}
@ -164,7 +223,7 @@ function LeadDetail({ lead }: { lead: Lead }) {
<label className="kx-vis__followup-label" htmlFor="kx-followup">
<IconSpark size={14} /> AI
</label>
<textarea id="kx-followup" className="kx-vis__followup-input" defaultValue={lead.followupDraft} rows={7} />
<textarea id="kx-followup" className="kx-vis__followup-input" defaultValue={lead.followupDraft ?? ''} rows={7} />
</div>
<div className="kx-vis__lead-actions">
@ -188,6 +247,7 @@ function LeadDetail({ lead }: { lead: Lead }) {
<span className="kx-vis__activity-at">{a.at}</span>
</li>
))}
{lead.activity.length === 0 && <li className="kx-vis__activity-empty"> </li>}
</ul>
</div>
</aside>
@ -212,6 +272,15 @@ function StarGlyph({ filled }: { filled: boolean }) {
);
}
/** 체크 글리프(선 SVG) — AI 근거 목록 마커(글리프 텍스트 ✓ 대체, QA 지적). */
function CheckGlyph() {
return (
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 12l5 5L20 6" />
</svg>
);
}
/** AI 스코어 게이지(0~100). 80+ 는 AI 액센트(보라). */
function ScoreGauge({ score }: { score: number }) {
const hot = score >= 80;
@ -224,3 +293,28 @@ function ScoreGauge({ score }: { score: number }) {
</span>
);
}
/** 폴백 리드(오프라인 강등 시) — sampleVisitor.LEADS 파생(마스킹 유지). */
const SAMPLE_LEADS: LeadItemDto[] = LEADS.map((l) => ({
id: l.id,
nameMasked: l.nameMasked,
role: l.role,
company: l.company,
product: l.product,
interest: l.interest,
score: l.score,
collectedAt: l.collectedAt,
phoneMasked: l.phoneMasked,
emailMasked: l.emailMasked,
aiReasons: l.aiReasons,
activity: l.activity,
followupDraft: l.followupDraft,
aiGenerated: true,
}));
function isDegradable(error: unknown): boolean {
return (
error instanceof ApiRequestError &&
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
);
}

View File

@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import {
Area,
AreaChart,
@ -12,12 +13,14 @@ import {
} from 'recharts';
import { AiLabel } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconDownload, IconSpark } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { CHART } from '../chartColors';
import {
CHECKIN_LABEL,
REG_FORMS,
REG_KPIS,
REG_TREND,
REG_TYPES,
REG_TYPE_LABEL,
@ -25,14 +28,38 @@ import {
type CheckinState,
type RegVisitorType,
} from './sampleVisitor';
import { visitorApi, type VisitorSummaryDto, type VisitorListItemDto } from './visitorApi';
import './visitor.css';
/*
* SCR-30 (M10·M11). Stitch scr_30_visitor_reg_dashboard .
* M10/M11 API , sampleVisitor ("샘플 데이터" ).
* 불변: 관람객 ·· (PII , N2/R10).
* 경로: GET /api/events/{eventId}/visitors/summary + /visitors ( · ).
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED sampleVisitor ("오프라인 — 샘플" ).
* 불변: 관람객 ·· ( , N2/R10).
*/
export function VisitorRegistrationDashboardPage() {
const eventId = useResolvedEventId();
const summaryQ = useQuery({
queryKey: ['visitor-summary', eventId],
queryFn: () => visitorApi.summary(eventId as string),
enabled: !!eventId,
retry: false,
});
const listQ = useQuery({
queryKey: ['visitor-list', eventId],
queryFn: () => visitorApi.visitors(eventId as string, 0, 20),
enabled: !!eventId,
retry: false,
});
const degraded = isDegradable(summaryQ.error) || isDegradable(listQ.error);
const summary: VisitorSummaryDto | null = summaryQ.data ?? (degraded ? SAMPLE_SUMMARY : null);
const rows: VisitorListItemDto[] =
listQ.data?.items ?? (degraded ? SAMPLE_ROWS : []);
const total = listQ.data?.total ?? (degraded ? SAMPLE_ROWS.length : 0);
const hardError = (summaryQ.isError && !degraded) || (listQ.isError && !degraded);
return (
<div className="kx-vis">
<header className="kx-vis__head">
@ -41,16 +68,30 @@ export function VisitorRegistrationDashboardPage() {
<p className="kx-vis__subtitle"> · · </p>
</div>
<div className="kx-vis__head-actions">
<span className="kx-sample" title="M10 관람 모듈 미연동 — 시연 데이터"> </span>
{degraded && (
<span className="kx-vis__degraded" title="집계 API 미연결 — 시연 데이터"> </span>
)}
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>
</Button>
</div>
</header>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="관람객 데이터를 조회할 행사가 지정되지 않았습니다." />
)}
{eventId && summaryQ.isLoading && !degraded && <VisSkeleton />}
{hardError && (
<ErrorState message="관람객 집계를 불러오지 못했습니다." onRetry={() => { summaryQ.refetch(); listQ.refetch(); }} />
)}
{summary && (
<>
{/* KPI 밴드 */}
<section className="kx-vis__kpis" aria-label="관람객 핵심 지표">
{REG_KPIS.map((k) => (
{summary.kpis.map((k) => (
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
<span className="kx-kpi__label">
{k.label}
@ -85,10 +126,10 @@ export function VisitorRegistrationDashboardPage() {
</li>
</ul>
</div>
<p className="kx-card__hint"> D-14 · .</p>
<p className="kx-card__hint"> · .</p>
<div className="kx-vis__chart">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={REG_TREND} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<AreaChart data={summary.trend} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<defs>
<linearGradient id="gPreReg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.primary600} stopOpacity={0.35} />
@ -101,7 +142,7 @@ export function VisitorRegistrationDashboardPage() {
</defs>
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={48} tickFormatter={(v) => `${Math.round((v as number) / 1000)}`} />
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={48} tickFormatter={(v) => (v as number >= 1000 ? `${Math.round((v as number) / 1000)}` : String(v))} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name) => [`${(value as number).toLocaleString()}`, name]}
@ -116,8 +157,8 @@ export function VisitorRegistrationDashboardPage() {
<div className="kx-vis__donut">
<ResponsiveContainer width="100%" height={140}>
<PieChart>
<Pie data={REG_TYPES} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={38} outerRadius={58} paddingAngle={2} strokeWidth={0}>
{REG_TYPES.map((s) => (
<Pie data={summary.types} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={38} outerRadius={58} paddingAngle={2} strokeWidth={0}>
{summary.types.map((s) => (
<Cell key={s.name} fill={s.color} />
))}
</Pie>
@ -128,7 +169,7 @@ export function VisitorRegistrationDashboardPage() {
<div className="kx-vis__donut-legend">
<p className="kx-vis__donut-title"> </p>
<ul>
{REG_TYPES.map((s) => (
{summary.types.map((s) => (
<li key={s.name}>
<span className="kx-dot" style={{ background: s.color }} />
{s.name}
@ -146,7 +187,7 @@ export function VisitorRegistrationDashboardPage() {
<h2> </h2>
</div>
<ul className="kx-vis__forms">
{REG_FORMS.map((f) => (
{summary.forms.map((f) => (
<li key={f.id} className="kx-vis__form">
<span className="kx-vis__form-name">
<span className={`kx-dot ${f.active ? 'is-on' : 'is-off'}`} />
@ -185,6 +226,7 @@ export function VisitorRegistrationDashboardPage() {
</div>
</div>
</div>
<p className="kx-vis__ai-note"> 릿( ).</p>
<Button variant="secondary" block> </Button>
</section>
</aside>
@ -209,17 +251,17 @@ export function VisitorRegistrationDashboardPage() {
</tr>
</thead>
<tbody>
{REGISTRANTS.map((r) => (
{rows.map((r) => (
<tr key={r.id}>
<td>
<span className="kx-vis__person">
<span className="kx-vis__avatar" aria-hidden="true">{r.initial}</span>
<span className="kx-vis__avatar" aria-hidden="true">{initialOf(r.nameMasked)}</span>
{r.nameMasked}
</span>
</td>
<td><TypePill type={r.type} /></td>
<td>{r.company}</td>
<td className="tnum">{r.registeredAt}</td>
<td>{r.company ?? '-'}</td>
<td className="tnum">{r.registeredAt ?? '-'}</td>
<td><CheckinPill state={r.checkin} /></td>
<td>
{r.badgeIssued ? (
@ -232,13 +274,34 @@ export function VisitorRegistrationDashboardPage() {
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6}>
<EmptyState title="등록자가 없습니다" description="사전등록이 접수되면 이 곳에 표시됩니다." />
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="kx-vis__table-foot">
<span className="kx-vis__count"> 12,480 16 </span>
<span className="kx-vis__count">
{total.toLocaleString()} {rows.length}
</span>
</div>
</section>
</>
)}
</div>
);
}
function VisSkeleton() {
return (
<div style={{ display: 'grid', gap: 16 }}>
<Skeleton height={92} radius={12} />
<Skeleton height={360} radius={12} />
<Skeleton height={260} radius={12} />
</div>
);
}
@ -250,23 +313,59 @@ const TOOLTIP_STYLE = {
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
} as const;
const TYPE_TONE: Record<RegVisitorType, string> = {
visitor: 'visitor',
buyer: 'buyer',
vip: 'vip',
};
function TypePill({ type }: { type: RegVisitorType }) {
return <span className={`kx-vis__type is-${TYPE_TONE[type]}`}>{REG_TYPE_LABEL[type]}</span>;
/** 마스킹 이름 첫 글자(아바타). */
function initialOf(nameMasked: string): string {
return nameMasked ? nameMasked.charAt(0) : '?';
}
function CheckinPill({ state }: { state: CheckinState }) {
const KNOWN_TYPES: RegVisitorType[] = ['visitor', 'buyer', 'vip'];
function TypePill({ type }: { type: string }) {
const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor';
return <span className={`kx-vis__type is-${t}`}>{REG_TYPE_LABEL[t]}</span>;
}
const KNOWN_CHECKIN: CheckinState[] = ['done', 'waiting', 'cancelled'];
function CheckinPill({ state }: { state: string }) {
const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting';
return (
<span className={`kx-vis__checkin is-${state}`}>
<span className="kx-dot" /> {CHECKIN_LABEL[state]}
<span className={`kx-vis__checkin is-${s}`}>
<span className="kx-dot" /> {CHECKIN_LABEL[s]}
</span>
);
}
/** 폴백 요약(오프라인 강등 시) — sampleVisitor 파생. */
const SAMPLE_SUMMARY: VisitorSummaryDto = {
kpis: [
{ label: '사전등록', value: '12,480' },
{ label: '체크인', value: '8,210', sub: '진행률 65.7%' },
{ label: '노쇼/취소', value: '1,540', trend: 'down', sub: '12%' },
{ label: '바이어 비중', value: '34%', sub: '목표 30%' },
{ label: '리드 생성', value: '2,140', ai: true, sub: 'AI 스코어링 대상' },
],
trend: REG_TREND.map((p) => ({ label: p.label, preReg: p.preReg, checkIn: p.checkIn })),
types: REG_TYPES.map((t) => ({ name: t.name, value: t.value, color: t.color })),
forms: REG_FORMS.map((f) => ({ id: f.id, name: f.name, active: f.active, count: f.count })),
};
/** 폴백 등록자 목록(오프라인 강등 시). */
const SAMPLE_ROWS: VisitorListItemDto[] = REGISTRANTS.map((r) => ({
id: r.id,
nameMasked: r.nameMasked,
type: r.type,
company: r.company,
registeredAt: r.registeredAt,
checkin: r.checkin,
badgeIssued: r.badgeIssued,
}));
function isDegradable(error: unknown): boolean {
return (
error instanceof ApiRequestError &&
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
);
}
/** QR 코드 글리프 (선 SVG, 장식용). */
function QrGlyph({ size = 40 }: { size?: number }) {
return (

View File

@ -48,6 +48,24 @@
white-space: nowrap;
}
/* 오프라인 강등(폴백) 표시 — 집계 API 미연결 시 시연 데이터 안내 */
.kx-vis__degraded {
font-size: 11px;
font-weight: 700;
color: var(--color-warning);
background: #fff4e5;
border: 1px solid #fcd9a8;
padding: 3px 10px;
border-radius: var(--radius-pill);
white-space: nowrap;
}
.kx-vis__activity-empty {
color: var(--color-text-muted, #667085);
font-size: 13px;
padding: 6px 0;
list-style: none;
}
/* KPI 밴드 */
.kx-vis__kpis {
display: grid;

View File

@ -0,0 +1,96 @@
/*
* M10 · API (SCR-30·SCR-32).
* (../../api/client) client.ts·endpoints.ts·types.ts .
* PII 불변: 응답에는 (nameMasked·phoneMasked·emailMasked) ( ).
*
* :
* GET /api/events/{eventId}/visitors/summary ApiResponse<VisitorSummary>
* GET /api/events/{eventId}/visitors?page&size ApiResponse<PageResponse<VisitorListItem>>
* GET /api/events/{eventId}/leads?boothId&page&size ApiResponse<PageResponse<LeadItem>>
* POST /api/public/events/{eventId}/register ( P4 )
*/
import { api } from '../../api/client';
import type { PageResponse } from '../../api/types';
// ── SCR-30 요약 ──
export interface VisitorKpiDto {
label: string;
value: string;
delta?: string | null;
trend?: string | null;
ai?: boolean | null;
sub?: string | null;
}
export interface TrendPointDto {
label: string;
preReg: number;
checkIn: number;
}
export interface TypeSliceDto {
name: string;
value: number;
color: string;
}
export interface FormItemDto {
id: string;
name: string;
active: boolean;
count: number;
}
export interface VisitorSummaryDto {
kpis: VisitorKpiDto[];
trend: TrendPointDto[];
types: TypeSliceDto[];
forms: FormItemDto[];
}
// ── SCR-30 등록자(마스킹) ──
export interface VisitorListItemDto {
id: string;
nameMasked: string;
type: string; // visitor|buyer|vip
company: string | null;
registeredAt: string | null;
checkin: string; // done|waiting|cancelled
badgeIssued: boolean;
}
// ── SCR-32 리드(마스킹 + AI 저장값) ──
export interface LeadActivityDto {
text: string;
at: string;
}
export interface LeadItemDto {
id: string;
nameMasked: string;
role: string | null;
company: string | null;
product: string | null;
interest: number;
score: number;
collectedAt: string | null;
phoneMasked: string | null;
emailMasked: string | null;
aiReasons: string[];
activity: LeadActivityDto[];
followupDraft: string | null;
aiGenerated: boolean;
}
export const visitorApi = {
summary: (eventId: string) =>
api.get<VisitorSummaryDto>(`/api/events/${encodeURIComponent(eventId)}/visitors/summary`),
visitors: (eventId: string, page = 0, size = 20) =>
api.get<PageResponse<VisitorListItemDto>>(
`/api/events/${encodeURIComponent(eventId)}/visitors?page=${page}&size=${size}`,
),
leads: (eventId: string, boothId?: string | null, page = 0, size = 50) => {
const qs = new URLSearchParams();
if (boothId) qs.set('boothId', boothId);
qs.set('page', String(page));
qs.set('size', String(size));
return api.get<PageResponse<LeadItemDto>>(
`/api/events/${encodeURIComponent(eventId)}/leads?${qs.toString()}`,
);
},
};