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:
parent
abc9f83690
commit
2854f068ed
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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/{id}/status?value= — 게시 전이(전진만·역전이 400, 승인/게시는 매니저↑)</li>
|
||||
* <li>GET/PUT /api/cms/contents/{id}/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));
|
||||
}
|
||||
}
|
||||
100
src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java
Normal file
100
src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java
Normal 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"}).
|
||||
* 게시 상태(draft→review→approved→published)·예약 게시(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);
|
||||
}
|
||||
160
src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java
Normal file
160
src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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/{exhibitorId}/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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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/{exhibitorId} → 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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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=save→draft, submit→submitted.
|
||||
* 대상 서류 미존재 → 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.zioinfo.kintex.document.dto;
|
||||
|
||||
/**
|
||||
* 서류 상태 전이 요청 (SCR-23 임시저장/제출).
|
||||
* action: save→draft, submit→submitted. 그 외 값은 400.
|
||||
*/
|
||||
public record DocumentTransitionRequest(
|
||||
String action
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.zioinfo.kintex.logistics.dto;
|
||||
|
||||
/** 하역장 도크(그리드 행). heavyPriority=중량물(5t↑) 우선 도크. */
|
||||
public record DockDto(
|
||||
String id,
|
||||
int dockNo,
|
||||
String label,
|
||||
boolean heavyPriority
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.zioinfo.kintex.logistics.dto;
|
||||
|
||||
/**
|
||||
* 도크 그리드 예약 블록 (SCR-25 좌측 스케줄). dockIndex 는 docks 목록 내 0-based 위치.
|
||||
* start=시작 시간대(08:00→0), span=점유 칸 수. tone: booked|priority.
|
||||
*/
|
||||
public record DockGridReservationDto(
|
||||
String id,
|
||||
int dockIndex,
|
||||
int start,
|
||||
int span,
|
||||
String label,
|
||||
String tone
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.zioinfo.kintex.publicsite.dto;
|
||||
|
||||
/**
|
||||
* 문의 접수 결과 (M12 / SCR-P6). 공개 응답 — 접수번호·상태만 반환(PII 미노출).
|
||||
*/
|
||||
public record InquiryReceiptDto(
|
||||
String receiptNo,
|
||||
String status
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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) + "%";
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
122
src/backend/src/main/resources/db/migration/V16__m15_auction.sql
Normal file
122
src/backend/src/main/resources/db/migration/V16__m15_auction.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
90
src/backend/src/main/resources/db/migration/V19__m17_cms.sql
Normal file
90
src/backend/src/main/resources/db/migration/V19__m17_cms.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.5–8.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,95 +48,175 @@ 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>
|
||||
|
||||
<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}>
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-adm-vitem ${v.version === selected ? 'is-active' : ''}`}
|
||||
onClick={() => setSelected(v.version)}
|
||||
>
|
||||
<span className="kx-adm-vitem__top">
|
||||
<strong>{v.version}</strong>
|
||||
<VersionBadge status={v.status} />
|
||||
</span>
|
||||
<span className="kx-adm-vitem__date">발효일 {v.effectiveDate}</span>
|
||||
{v.note && <span className="kx-adm-vitem__note">{v.note}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
{listQ.isLoading && <RulesetSkeleton />}
|
||||
{listQ.isError && (
|
||||
<ErrorState message="룰셋 목록을 불러오지 못했습니다." onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
|
||||
{/* 우: 규칙 목록 */}
|
||||
<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>
|
||||
{!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>
|
||||
</div>
|
||||
<button className="kx-btn kx-btn--secondary" disabled title="API 미제공">
|
||||
버전 비교(diff)
|
||||
</button>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-table kx-table--zebra kx-adm-rules">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>규칙명</th>
|
||||
<th>수치/설정값</th>
|
||||
<th>논리 / 요구사항</th>
|
||||
<th>위반 수준</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{current.rules.map((r) => (
|
||||
<tr key={r.code}>
|
||||
<td>
|
||||
<span className="kx-adm-rulename">{r.label}</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>
|
||||
<span className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}>
|
||||
{r.severity === 'block' ? '차단' : '경고'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="kx-adm-disclaimer">
|
||||
본 룰셋은 사전 필터이며 최종 승인은 킨텍스 및 구조기술사의 판단에 따릅니다.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<ul className="kx-adm-vlist">
|
||||
{versions.map((v) => (
|
||||
<li key={v.name}>
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-adm-vitem ${v.name === activeName ? 'is-active' : ''}`}
|
||||
onClick={() => setSelected(v.name)}
|
||||
>
|
||||
<span className="kx-adm-vitem__top">
|
||||
<strong>{v.version}</strong>
|
||||
<span className="kx-adm-pill kx-adm-pill--neutral">규칙 {v.ruleCount}</span>
|
||||
</span>
|
||||
<span className="kx-adm-vitem__date">
|
||||
{v.effectiveDate ? `발효일 ${v.effectiveDate}` : v.name}
|
||||
</span>
|
||||
<ModuleDistribution dist={v.moduleDistribution} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* 우: 규칙 목록 */}
|
||||
<section className="kx-card kx-adm-ruleset__editor" aria-label="규칙 목록">
|
||||
<div className="kx-adm-ruleset__ehead">
|
||||
<div>
|
||||
<h2>
|
||||
{current?.version ?? activeName} 규칙 ({rules.length}개)
|
||||
</h2>
|
||||
<p className="kx-adm-muted">
|
||||
{detailQ.data?.effectiveDate
|
||||
? `발효일 ${detailQ.data.effectiveDate}`
|
||||
: current?.name}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<tr>
|
||||
<th>규칙명</th>
|
||||
<th>수치/설정값</th>
|
||||
<th>논리 / 요구사항</th>
|
||||
<th>위반 수준</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((r) => (
|
||||
<tr key={r.code}>
|
||||
<td>
|
||||
<span className="kx-adm-rulename">{r.label ?? r.code}</span>
|
||||
<span className="kx-adm-rulecode">{r.code}</span>
|
||||
</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'}`}
|
||||
>
|
||||
{r.severity === 'block' ? '차단' : '경고'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)}`),
|
||||
};
|
||||
|
||||
219
src/frontend/src/screens/auction/auctionApi.ts
Normal file
219
src/frontend/src/screens/auction/auctionApi.ts
Normal 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'),
|
||||
};
|
||||
@ -1,11 +1,13 @@
|
||||
/*
|
||||
* SCR-35 CMS 콘텐츠·게시 워크플로 [M17]. 참조: design.md §3 SCR-35.
|
||||
* 좌: 콘텐츠 트리/목록(상태 배지·언어 탭) · 중: 블록 에디터(툴바·미디어·예약 게시) · 우: 게시 워크플로(버전·검수→승인→게시·사이니지 토글).
|
||||
* M17 백엔드 미구현 → 전부 로컬 샘플 상태 데모("샘플 데이터" 배지). 존재하지 않는 API 호출 없음.
|
||||
* 좌: 콘텐츠 목록(상태 배지) · 중: 에디터(제목·블록·미디어) · 우: 게시 워크플로(전이 draft→review→approved→published·예약·사이니지 토글).
|
||||
* ★실 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,88 +42,138 @@ 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>
|
||||
<ul className="kx-cms-list">
|
||||
{content.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' : ''}`}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
aria-current={c.id === selected.id}
|
||||
>
|
||||
<span className="kx-cms-list__label">
|
||||
<IconDocument size={18} />
|
||||
<span className="kx-cms-list__name">{c.name}</span>
|
||||
</span>
|
||||
<span className={`kx-cms-pill ${m.cls}`}>{m.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{loading ? (
|
||||
<div className="kx-cms-empty">불러오는 중…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="kx-cms-empty">콘텐츠가 없습니다. “새 콘텐츠”로 초안을 만드세요.</div>
|
||||
) : (
|
||||
<ul className="kx-cms-list">
|
||||
{items.map((c) => {
|
||||
const m = STATUS_META[c.status];
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
className={`kx-cms-list__item ${c.id === selectedId ? 'is-active' : ''}`}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
aria-current={c.id === selectedId}
|
||||
>
|
||||
<span className="kx-cms-list__label">
|
||||
<IconDocument size={18} />
|
||||
<span className="kx-cms-list__name">{c.title}</span>
|
||||
</span>
|
||||
<span className={`kx-cms-pill ${m.cls}`}>{m.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
|
||||
@ -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,81 +46,169 @@ 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>
|
||||
<ul className="kx-mb-sec">
|
||||
{sections.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
className={`kx-mb-sec__item ${s.id === activeSection ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
aria-current={s.id === activeSection}
|
||||
>
|
||||
<span className="kx-mb-sec__label">
|
||||
<MiniIcon d={D_DRAG} />
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="kx-mb-sec__ctrls">
|
||||
<span
|
||||
className="kx-icon-btn"
|
||||
title={s.visible ? '표시 중' : '숨김'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSections((prev) =>
|
||||
prev.map((x) => (x.id === s.id ? { ...x, visible: !x.visible } : x)),
|
||||
);
|
||||
}}
|
||||
onKeyDown={() => {}}
|
||||
>
|
||||
<MiniIcon d={D_EYE} />
|
||||
{loading ? (
|
||||
<div className="kx-cms-empty">불러오는 중…</div>
|
||||
) : (
|
||||
<ul className="kx-mb-sec">
|
||||
{sections.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
className={`kx-mb-sec__item ${s.id === activeSection ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
aria-current={s.id === activeSection}
|
||||
>
|
||||
<span className="kx-mb-sec__label">
|
||||
<MiniIcon d={D_DRAG} />
|
||||
{SECTION_NAMES[s.type] ?? s.type}
|
||||
</span>
|
||||
<span className="kx-icon-btn" title="편집" aria-hidden="true">
|
||||
<MiniIcon d={D_EDIT} />
|
||||
<span className="kx-mb-sec__ctrls">
|
||||
<span
|
||||
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={(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>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="kx-mb-addbtn">
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<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,61 +286,59 @@ export function MicrositeBuilderPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 제품 그리드 */}
|
||||
<section className="kx-mb-section kx-mb-section--alt">
|
||||
<div className="kx-mb-section__head">
|
||||
<span className="kx-mb-section__bar" />
|
||||
<h3 className="kx-mb-section__title">주요 혁신 제품</h3>
|
||||
</div>
|
||||
<div className="kx-mb-cards">
|
||||
<article className="kx-mb-card">
|
||||
<div className="kx-mb-card__img">
|
||||
<IconImage size={24} />
|
||||
<span className="kx-mb-card__tag">NEW</span>
|
||||
</div>
|
||||
<div className="kx-mb-card__body">
|
||||
<div className="kx-mb-card__title">HR-A1 협동로봇</div>
|
||||
<p className="kx-mb-card__desc">
|
||||
고정밀 센서·AI 비전 시스템 탑재 다목적 협동로봇으로 안전한 협업 환경 제공.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="kx-mb-card">
|
||||
<div className="kx-mb-card__img">
|
||||
<IconImage size={24} />
|
||||
<span className="kx-mb-card__tag">POPULAR</span>
|
||||
</div>
|
||||
<div className="kx-mb-card__body">
|
||||
<div className="kx-mb-card__title">ServiBot Elite</div>
|
||||
<p className="kx-mb-card__desc">
|
||||
전시장·호텔용 지능형 서비스 로봇 — 다국어 응대·자율 주행 안내.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</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" />
|
||||
<h3 className="kx-mb-section__title">주요 혁신 제품</h3>
|
||||
</div>
|
||||
<div className="kx-mb-cards">
|
||||
<article className="kx-mb-card">
|
||||
<div className="kx-mb-card__img">
|
||||
<IconImage size={24} />
|
||||
<span className="kx-mb-card__tag">NEW</span>
|
||||
</div>
|
||||
<div className="kx-mb-card__body">
|
||||
<div className="kx-mb-card__title">HR-A1 협동로봇</div>
|
||||
<p className="kx-mb-card__desc">고정밀 센서·AI 비전 시스템 탑재 다목적 협동로봇.</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="kx-mb-card">
|
||||
<div className="kx-mb-card__img">
|
||||
<IconImage size={24} />
|
||||
<span className="kx-mb-card__tag">POPULAR</span>
|
||||
</div>
|
||||
<div className="kx-mb-card__body">
|
||||
<div className="kx-mb-card__title">ServiBot Elite</div>
|
||||
<p className="kx-mb-card__desc">전시장·호텔용 지능형 서비스 로봇.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* AI 예상샷 갤러리 */}
|
||||
<section className="kx-mb-section">
|
||||
<div className="kx-mb-section__head">
|
||||
<span className="kx-mb-section__bar" style={{ background: 'var(--color-ai-accent)' }} />
|
||||
<h3 className="kx-mb-section__title">AI 부스 예상 시뮬레이션</h3>
|
||||
</div>
|
||||
<div className="kx-mb-gallery">
|
||||
<div className="kx-mb-gallery__cell">
|
||||
<IconGrid size={28} />
|
||||
<span className="kx-mb-gallery__wm">
|
||||
<AiLabel>AI 생성 예상</AiLabel>
|
||||
</span>
|
||||
{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)' }} />
|
||||
<h3 className="kx-mb-section__title">AI 부스 예상 시뮬레이션</h3>
|
||||
</div>
|
||||
<div className="kx-mb-gallery__cell">
|
||||
<IconGrid size={28} />
|
||||
<span className="kx-mb-gallery__wm">
|
||||
<AiLabel>AI 생성 예상</AiLabel>
|
||||
</span>
|
||||
<div className="kx-mb-gallery">
|
||||
<div className="kx-mb-gallery__cell">
|
||||
<IconGrid size={28} />
|
||||
<span className="kx-mb-gallery__wm">
|
||||
<AiLabel>AI 생성 예상</AiLabel>
|
||||
</span>
|
||||
</div>
|
||||
<div className="kx-mb-gallery__cell">
|
||||
<IconGrid size={28} />
|
||||
<span className="kx-mb-gallery__wm">
|
||||
<AiLabel>AI 생성 예상</AiLabel>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
|
||||
@ -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)),
|
||||
);
|
||||
}
|
||||
function applyReviewed() {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === selected.id ? { ...r, en: { ...r.en, status: 'reviewed', updated: '방금' } } : 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);
|
||||
}
|
||||
}
|
||||
|
||||
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,120 +186,119 @@ 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">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const m = STATUS_META[r.en.status];
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={r.id === selected.id ? 'is-active' : ''}
|
||||
onClick={() => setSelectedId(r.id)}
|
||||
>
|
||||
<td>
|
||||
<div className="kx-i18n-row__key">{r.key}</div>
|
||||
<div className="kx-i18n-row__id">{r.id}</div>
|
||||
</td>
|
||||
<td className={r.en.text ? '' : 'kx-i18n-row__trans--empty'}>
|
||||
{r.en.text || '번역 대기 중…'}
|
||||
</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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{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: '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>
|
||||
{contents.map((c) => {
|
||||
const t = transMap[c.id]?.[target];
|
||||
const st = t?.transStatus ?? 'none';
|
||||
const m = STATUS_META[st];
|
||||
return (
|
||||
<tr
|
||||
key={c.id}
|
||||
className={c.id === selectedId ? 'is-active' : ''}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
>
|
||||
<td>
|
||||
<div className="kx-i18n-row__key">{c.title}</div>
|
||||
<div className="kx-i18n-row__id">{c.id}</div>
|
||||
</td>
|
||||
<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">{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>
|
||||
|
||||
<div className="kx-cms-field">
|
||||
<span className="kx-cms-label">Selected ID</span>
|
||||
<div className="kx-i18n-row__id" style={{ color: 'var(--color-primary-700)', fontWeight: 700 }}>
|
||||
{selected.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-cms-field">
|
||||
<span className="kx-cms-label">원문 (KO)</span>
|
||||
<div className="kx-i18n-src">{selected.source}</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-cms-field">
|
||||
<span className="kx-cms-label">번역 (EN)</span>
|
||||
<textarea
|
||||
className="kx-cms-textarea kx-i18n-editor__ta"
|
||||
value={selected.en.text}
|
||||
onChange={(e) => updateSelectedText(e.target.value)}
|
||||
placeholder="번역을 입력하거나 AI 재생성하세요"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
leadingIcon={<IconSpark size={14} />}
|
||||
onClick={() => updateSelectedText(`[AI] ${selected.source}`)}
|
||||
>
|
||||
AI 재생성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-cms-field">
|
||||
<span className="kx-cms-sublabel">Glossary</span>
|
||||
<div className="kx-i18n-glossary">
|
||||
{GLOSSARY.map((g) => (
|
||||
<div className="kx-i18n-glossary__row" key={g.ko}>
|
||||
<b>{g.ko}</b>
|
||||
<span>→ {g.en}</span>
|
||||
{!selected ? (
|
||||
<div className="kx-cms-empty">좌측에서 콘텐츠를 선택하세요.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="kx-cms-field">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</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-cms-field">
|
||||
<span className="kx-cms-label">원문 (KO)</span>
|
||||
<div className="kx-i18n-src">{transMap[selected.id]?.ko?.title || selected.title}</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-i18n-editor__actions">
|
||||
<Button variant="secondary">취소</Button>
|
||||
<Button onClick={applyReviewed}>적용 · 검수완료</Button>
|
||||
</div>
|
||||
<div className="kx-cms-field">
|
||||
<span className="kx-cms-label">번역 ({target.toUpperCase()})</span>
|
||||
<textarea
|
||||
className="kx-cms-textarea kx-i18n-editor__ta"
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
placeholder="번역을 입력하세요"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="ghost" leadingIcon={<IconSpark size={14} />} disabled title={AI_DISABLED_HINT}>
|
||||
AI 재생성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-cms-field">
|
||||
<span className="kx-cms-sublabel">Glossary</span>
|
||||
<div className="kx-i18n-glossary">
|
||||
{GLOSSARY.map((g) => (
|
||||
<div className="kx-i18n-glossary__row" key={g.ko}>
|
||||
<b>{g.ko}</b>
|
||||
<span>→ {g.en}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-i18n-editor__actions">
|
||||
<Button variant="secondary" onClick={() => save('ai')} disabled={busy}>
|
||||
임시 저장
|
||||
</Button>
|
||||
<Button onClick={() => save('reviewed')} disabled={busy}>
|
||||
적용 · 검수완료
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
117
src/frontend/src/screens/cms/cmsApi.ts
Normal file
117
src/frontend/src/screens/cms/cmsApi.ts
Normal 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 }),
|
||||
};
|
||||
@ -1,12 +1,13 @@
|
||||
/*
|
||||
* SCR-22 서류·마일스톤 워크플로 [M6]. Stitch scr_22_docs_milestones 이식.
|
||||
* 진입: 주최자 대시보드 마일스톤 노드 / 사이드바 "서류·마일스톤" (design.md §3 SCR-22).
|
||||
* 구성: ①상단 마일스톤 진행 바(D-150→D-0) ②좌 신고서류 체크리스트(8종·상태·D-데이·액션)
|
||||
* 구성: ①상단 마일스톤 진행 바(D-150→D-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,35 +124,44 @@ 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>
|
||||
<div className="kx-doc__stepper">
|
||||
<span className="kx-doc__track" aria-hidden="true" />
|
||||
<span
|
||||
className="kx-doc__track-fill"
|
||||
aria-hidden="true"
|
||||
style={{ width: `calc((100% - var(--space-5) * 2) * ${fillPct / 100})` }}
|
||||
{milestonesQ.isPending ? (
|
||||
<Skeleton height={72} />
|
||||
) : milestones.length === 0 ? (
|
||||
<EmptyState
|
||||
title="구성된 마일스톤이 없습니다"
|
||||
description="행사를 생성하면 마일스톤이 자동 구성됩니다."
|
||||
/>
|
||||
{MILESTONES.map((m) => (
|
||||
<div key={m.label} className={`kx-doc__step is-${m.state}`}>
|
||||
<span className="kx-doc__node">
|
||||
{m.state === 'done' ? (
|
||||
<IconCheck size={20} />
|
||||
) : (
|
||||
<span className="tnum" aria-hidden="true">
|
||||
{m.state === 'active' ? '⏳' : '·'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="kx-doc__step-label">{m.label}</span>
|
||||
<span className="kx-doc__step-sub">{m.sub}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="kx-doc__stepper">
|
||||
<span className="kx-doc__track" aria-hidden="true" />
|
||||
<span
|
||||
className="kx-doc__track-fill"
|
||||
aria-hidden="true"
|
||||
style={{ width: `calc((100% - var(--space-5) * 2) * ${fillPct / 100})` }}
|
||||
/>
|
||||
{milestones.map((m) => (
|
||||
<div key={m.label} className={`kx-doc__step is-${m.state}`}>
|
||||
<span className="kx-doc__node">
|
||||
{m.state === 'done' ? (
|
||||
<IconCheck size={20} />
|
||||
) : (
|
||||
<span className="tnum" aria-hidden="true">
|
||||
{m.state === 'active' ? '⏳' : '·'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="kx-doc__step-label">{m.label}</span>
|
||||
<span className="kx-doc__step-sub">{m.sub}</span>
|
||||
</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) => (
|
||||
<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>
|
||||
{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.description}</p>
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
56
src/frontend/src/screens/docs/docsApi.ts
Normal file
56
src/frontend/src/screens/docs/docsApi.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
@ -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,180 +87,234 @@ 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>
|
||||
|
||||
<section className="kx-mkt__kpis" aria-label="캠페인 핵심 지표">
|
||||
{CAMPAIGN_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">
|
||||
{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>
|
||||
{!eventId && (
|
||||
<EmptyState title="행사를 선택해 주세요" description="캠페인을 조회할 행사가 지정되지 않았습니다." />
|
||||
)}
|
||||
|
||||
<div className="kx-mkt__split">
|
||||
{/* 캠페인 리스트 */}
|
||||
<section className="kx-mkt__list-col" aria-label="캠페인 목록">
|
||||
<div className="kx-mkt__list-head">
|
||||
<h2>캠페인 목록</h2>
|
||||
<div className="kx-mkt__tabs" role="tablist" aria-label="상태 필터">
|
||||
{(['all', 'active', 'done'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
aria-selected={filter === t}
|
||||
className={`kx-chip ${filter === t ? 'is-active' : ''}`}
|
||||
onClick={() => setFilter(t)}
|
||||
>
|
||||
{t === 'all' ? '전체' : t === 'active' ? '진행중' : '완료'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="kx-mkt__campaigns">
|
||||
{rows.map((c) => (
|
||||
<CampaignRow key={c.id} campaign={c} />
|
||||
{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="캠페인 핵심 지표">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* 캠페인 빌더 */}
|
||||
<aside className="kx-mkt__builder" aria-label="캠페인 빌더">
|
||||
<div className="kx-card">
|
||||
<div className="kx-card__head">
|
||||
<h2>캠페인 빌더</h2>
|
||||
<AiLabel>AI 최적화</AiLabel>
|
||||
{createMut.isError && (
|
||||
<div className="kx-vis__degraded" role="alert" style={{ marginBottom: 12 }}>
|
||||
캠페인 생성에 실패했습니다. 권한 또는 입력값을 확인해 주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="kx-mkt__field-label">대상 세그먼트</p>
|
||||
<div className="kx-mkt__segments">
|
||||
{SEGMENTS.map((s) => (
|
||||
<label key={s.id} className="kx-mkt__segment">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!segments[s.id]}
|
||||
onChange={(e) => setSegments((prev) => ({ ...prev, [s.id]: e.target.checked }))}
|
||||
/>
|
||||
<span>{s.label}</span>
|
||||
<span className="kx-mkt__segment-count tnum">{s.count.toLocaleString()}명</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="kx-mkt__ai-box">
|
||||
<div className="kx-mkt__ai-box-head">
|
||||
<span>AI 최적화</span>
|
||||
<IconSpark size={16} />
|
||||
<div className="kx-mkt__split">
|
||||
{/* 캠페인 리스트 */}
|
||||
<section className="kx-mkt__list-col" aria-label="캠페인 목록">
|
||||
<div className="kx-mkt__list-head">
|
||||
<h2>캠페인 목록</h2>
|
||||
<div className="kx-mkt__tabs" role="tablist" aria-label="상태 필터">
|
||||
{(['all', 'active', 'done'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
aria-selected={filter === t}
|
||||
className={`kx-chip ${filter === t ? 'is-active' : ''}`}
|
||||
onClick={() => setFilter(t)}
|
||||
>
|
||||
{t === 'all' ? '전체' : t === 'active' ? '진행중' : '완료'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p>세그먼트 성향을 분석해 오픈율이 가장 높을 카피와 발송 시간을 제안합니다.</p>
|
||||
<Button variant="ai" block leadingIcon={<IconSpark size={16} />}>
|
||||
AI 카피 초안 생성
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="kx-mkt__campaigns">
|
||||
{rows.map((c) => (
|
||||
<CampaignRow key={c.id} campaign={c} />
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<li>
|
||||
<EmptyState title="캠페인이 없습니다" description="우측 빌더에서 새 캠페인을 생성하세요." />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="kx-mkt__field-label">메인 이미지 에셋</p>
|
||||
<button type="button" className="kx-mkt__image-slot">
|
||||
<IconImage size={28} />
|
||||
<span>나노바나나 예상 이미지 삽입</span>
|
||||
</button>
|
||||
{/* 캠페인 빌더 */}
|
||||
<aside className="kx-mkt__builder" aria-label="캠페인 빌더">
|
||||
<div className="kx-card">
|
||||
<div className="kx-card__head">
|
||||
<h2>캠페인 빌더</h2>
|
||||
<AiLabel>AI 최적화</AiLabel>
|
||||
</div>
|
||||
|
||||
<p className="kx-mkt__field-label">발송 설정</p>
|
||||
<div className="kx-mkt__schedule">
|
||||
<div className="kx-seg" role="tablist" aria-label="발송 시점">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={schedule === 'now'}
|
||||
className={`kx-seg__btn ${schedule === 'now' ? 'is-active' : ''}`}
|
||||
onClick={() => setSchedule('now')}
|
||||
>
|
||||
즉시 발송
|
||||
<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) => (
|
||||
<label key={s.id} className="kx-mkt__segment">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!segments[s.id]}
|
||||
onChange={(e) => setSegments((prev) => ({ ...prev, [s.id]: e.target.checked }))}
|
||||
/>
|
||||
<span>{s.label}</span>
|
||||
<span className="kx-mkt__segment-count tnum">{s.count.toLocaleString()}명</span>
|
||||
</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">
|
||||
<span>AI 최적화</span>
|
||||
<IconSpark size={16} />
|
||||
</div>
|
||||
<p>세그먼트 성향을 분석해 오픈율이 가장 높을 카피와 발송 시간을 제안합니다.</p>
|
||||
<Button variant="ai" block leadingIcon={<IconSpark size={16} />}>
|
||||
AI 카피 초안 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="kx-mkt__field-label">메인 이미지 에셋</p>
|
||||
<button type="button" className="kx-mkt__image-slot">
|
||||
<IconImage size={28} />
|
||||
<span>나노바나나 예상 이미지 삽입</span>
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={schedule === 'later'}
|
||||
className={`kx-seg__btn ${schedule === 'later' ? 'is-active' : ''}`}
|
||||
onClick={() => setSchedule('later')}
|
||||
>
|
||||
예약 발송
|
||||
</button>
|
||||
</div>
|
||||
{schedule === 'later' && (
|
||||
<input type="datetime-local" className="kx-mkt__datetime" aria-label="예약 발송 일시" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kx-mkt__preview">
|
||||
<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-text">
|
||||
안녕하세요, 고객님. KINTEX가 제안하는 미래 제조 혁신의 현장으로 귀하를 정중히 모십니다.
|
||||
<p className="kx-mkt__field-label">발송 설정</p>
|
||||
<div className="kx-mkt__schedule">
|
||||
<div className="kx-seg" role="tablist" aria-label="발송 시점">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={schedule === 'now'}
|
||||
className={`kx-seg__btn ${schedule === 'now' ? 'is-active' : ''}`}
|
||||
onClick={() => setSchedule('now')}
|
||||
>
|
||||
즉시 발송
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={schedule === 'later'}
|
||||
className={`kx-seg__btn ${schedule === 'later' ? 'is-active' : ''}`}
|
||||
onClick={() => setSchedule('later')}
|
||||
>
|
||||
예약 발송
|
||||
</button>
|
||||
</div>
|
||||
{schedule === 'later' && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="kx-mkt__datetime"
|
||||
aria-label="예약 발송 일시"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kx-mkt__preview">
|
||||
<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">{name.trim() || '2026 KINTEX 스마트팩토리 엑스포에 초대합니다'}</p>
|
||||
<p className="kx-mkt__preview-text">
|
||||
안녕하세요, 고객님. KINTEX가 제안하는 미래 제조 혁신의 현장으로 귀하를 정중히 모십니다.
|
||||
</p>
|
||||
<footer className="kx-mkt__preview-foot">
|
||||
<p>본 메일은 수신동의를 하신 고객님께 발송되었습니다.</p>
|
||||
<span className="kx-mkt__preview-links">
|
||||
<a href="#unsubscribe" onClick={(e) => e.preventDefault()}>수신거부</a>
|
||||
<span>정보통신망법 수신동의 준수</span>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button block onClick={submitCampaign} disabled={!canCreate}>
|
||||
{createMut.isPending ? '생성 중…' : schedule === 'later' ? '예약 캠페인 등록' : '캠페인 초안 저장'}
|
||||
</Button>
|
||||
<p className="kx-vis__ai-note" style={{ marginTop: 8 }}>
|
||||
실제 발송(게이트웨이 연동)은 후속 단계에서 제공됩니다 — 현재는 캠페인 등록·상태 관리만 수행합니다.
|
||||
</p>
|
||||
<footer className="kx-mkt__preview-foot">
|
||||
<p>본 메일은 수신동의를 하신 고객님께 발송되었습니다.</p>
|
||||
<span className="kx-mkt__preview-links">
|
||||
<a href="#unsubscribe" onClick={(e) => e.preventDefault()}>수신거부</a>
|
||||
<span>정보통신망법 수신동의 준수</span>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button block>캠페인 실행하기</Button>
|
||||
</aside>
|
||||
</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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,106 +48,138 @@ 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>
|
||||
|
||||
<section className="kx-mkt__kpis" aria-label="스폰서십 핵심 지표">
|
||||
{SPONSOR_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">
|
||||
{k.value}
|
||||
{k.unit && <span className="kx-kpi__unit">{k.unit}</span>}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{!eventId && (
|
||||
<EmptyState title="행사를 선택해 주세요" description="스폰서십 현황을 조회할 행사가 지정되지 않았습니다." />
|
||||
)}
|
||||
|
||||
<div className="kx-mkt__split">
|
||||
{/* 티어 카드 */}
|
||||
<section className="kx-mkt__tiers-col" aria-label="스폰서십 패키지">
|
||||
<div className="kx-mkt__list-head">
|
||||
<h2>스폰서십 패키지</h2>
|
||||
<button type="button" className="kx-mkt__link">패키지 설정 관리</button>
|
||||
</div>
|
||||
<div className="kx-mkt__tiers">
|
||||
{SPONSOR_TIERS.map((t) => (
|
||||
<TierCard key={t.id} tier={t} />
|
||||
))}
|
||||
</div>
|
||||
{eventId && q.isLoading && !degraded && (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<Skeleton height={92} radius={12} />
|
||||
<Skeleton height={420} radius={12} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kx-mkt__deadline">
|
||||
<div className="kx-mkt__deadline-info">
|
||||
<span className="kx-mkt__deadline-icon" aria-hidden="true">
|
||||
<IconArrowRight size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>스폰서십 체결 일정</h3>
|
||||
<p>최종 마감일까지 14일 남았습니다. 주요 기업 팔로업이 필요합니다.</p>
|
||||
{hardError && <ErrorState message="스폰서십 현황을 불러오지 못했습니다." onRetry={() => q.refetch()} />}
|
||||
|
||||
{view && (
|
||||
<>
|
||||
<section className="kx-mkt__kpis" aria-label="스폰서십 핵심 지표">
|
||||
{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">
|
||||
{k.value}
|
||||
{k.unit && <span className="kx-kpi__unit">{k.unit}</span>}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">전체 일정 보기</Button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 스폰서·이행물 관리 */}
|
||||
<aside className="kx-mkt__sponsor-col" aria-label="스폰서 관리">
|
||||
<div className="kx-card">
|
||||
<div className="kx-card__head">
|
||||
<h2>스폰서 관리</h2>
|
||||
</div>
|
||||
<ul className="kx-mkt__sponsors">
|
||||
{SPONSORS.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-mkt__sponsor ${s.id === active?.id ? 'is-selected' : ''}`}
|
||||
aria-pressed={s.id === active?.id}
|
||||
onClick={() => setSelectedSponsor(s.id)}
|
||||
>
|
||||
<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>
|
||||
<SponsorPill sponsor={s} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="kx-mkt__split">
|
||||
{/* 티어 카드 */}
|
||||
<section className="kx-mkt__tiers-col" aria-label="스폰서십 패키지">
|
||||
<div className="kx-mkt__list-head">
|
||||
<h2>스폰서십 패키지</h2>
|
||||
<button type="button" className="kx-mkt__link">패키지 설정 관리</button>
|
||||
</div>
|
||||
<div className="kx-mkt__tiers">
|
||||
{view.tiers.map((t) => (
|
||||
<TierCard key={t.id} tier={t} />
|
||||
))}
|
||||
{view.tiers.length === 0 && (
|
||||
<EmptyState title="등록된 패키지가 없습니다" description="스폰서십 티어를 등록하세요." />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kx-card">
|
||||
<div className="kx-card__head">
|
||||
<h2>{active?.name} 이행 현황</h2>
|
||||
</div>
|
||||
<ul className="kx-mkt__fulfillment">
|
||||
{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} />}
|
||||
<div className="kx-mkt__deadline">
|
||||
<div className="kx-mkt__deadline-info">
|
||||
<span className="kx-mkt__deadline-icon" aria-hidden="true">
|
||||
<IconArrowRight size={20} />
|
||||
</span>
|
||||
{f.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="kx-mkt__roi-link">
|
||||
<span>노출 · 리드 · ROI 대시보드</span>
|
||||
<IconArrowRight size={18} />
|
||||
</button>
|
||||
<div>
|
||||
<h3>스폰서십 체결 일정</h3>
|
||||
<p>주요 기업 팔로업이 필요합니다. 마감이 임박한 패키지를 우선 확인하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">전체 일정 보기</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 스폰서·이행물 관리 */}
|
||||
<aside className="kx-mkt__sponsor-col" aria-label="스폰서 관리">
|
||||
<div className="kx-card">
|
||||
<div className="kx-card__head">
|
||||
<h2>스폰서 관리</h2>
|
||||
</div>
|
||||
<ul className="kx-mkt__sponsors">
|
||||
{sponsors.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-mkt__sponsor ${s.id === active?.id ? 'is-selected' : ''}`}
|
||||
aria-pressed={s.id === active?.id}
|
||||
onClick={() => setSelectedSponsor(s.id)}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<ul className="kx-mkt__fulfillment">
|
||||
{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} />}
|
||||
</span>
|
||||
{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>
|
||||
</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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
76
src/frontend/src/screens/marketing/marketingApi.ts
Normal file
76
src/frontend/src/screens/marketing/marketingApi.ts
Normal 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`),
|
||||
};
|
||||
@ -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 & Generate Logistics Pass</span>
|
||||
</span>
|
||||
<span className="kx-dock__qr-glyph" aria-hidden="true">
|
||||
<QrGlyph />
|
||||
@ -273,32 +309,45 @@ 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>
|
||||
</div>
|
||||
<div className="kx-dock__chart" role="img" aria-label="시간대별 차량 밀집도 예측 — 오후 4시경 피크">
|
||||
{QUEUE_FORECAST.map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="kx-dock__bar"
|
||||
style={{ height: `${h}%`, opacity: 0.35 + (h / 100) * 0.65 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="kx-dock__chart-axis tnum">
|
||||
<span>08:00</span>
|
||||
<span>12:00</span>
|
||||
<span>16:00</span>
|
||||
<span>20:00</span>
|
||||
</div>
|
||||
<div className="kx-dock__ai-callout">
|
||||
철거 당일 <strong>오후 4시경</strong> 차량 밀집도가 매우 높을 것으로 예상됩니다. 예약
|
||||
분산을 권장합니다.
|
||||
<AiLabel>{forecastQ.data?.fallback ? 'AI 예측 · 폴백' : 'AI 예측'}</AiLabel>
|
||||
</div>
|
||||
{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"
|
||||
style={{ height: `${h}%`, opacity: 0.35 + (h / 100) * 0.65 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="kx-dock__chart-axis tnum">
|
||||
<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">
|
||||
{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>;
|
||||
}
|
||||
|
||||
65
src/frontend/src/screens/movein/logisticsApi.ts
Normal file
65
src/frontend/src/screens/movein/logisticsApi.ts
Normal 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)}`,
|
||||
),
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,62 +110,79 @@ export function PublicFloorplanPage() {
|
||||
aria-label="부스 검색"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="kxp-fp__label">분야별 필터</p>
|
||||
<div className="kxp-fp__chips">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`kxp-fp__chip${cat === c ? ' is-active' : ''}`}
|
||||
aria-pressed={cat === c}
|
||||
onClick={() => setCat(cat === c ? null : c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<p className="kxp-fp__label">부스 유형 필터</p>
|
||||
<div className="kxp-fp__chips">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`kxp-fp__chip${cat === c ? ' is-active' : ''}`}
|
||||
aria-pressed={cat === c}
|
||||
onClick={() => setCat(cat === c ? null : c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<span className="kxp-sample">샘플 데이터</span>
|
||||
)}
|
||||
{halls.length > 0 && (
|
||||
<div>
|
||||
<p className="kxp-fp__label">전시장 선택</p>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kxp-fp__list" aria-label="부스 목록 (비시각 대안)">
|
||||
<p className="kxp-fp__listhint">검색 결과 ({filtered.length})</p>
|
||||
{filtered.length === 0 && <p className="kxp-empty">일치하는 부스가 없습니다.</p>}
|
||||
{filtered.map((b) => (
|
||||
<button
|
||||
key={b.code}
|
||||
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'}
|
||||
>
|
||||
<span>
|
||||
<span className="kxp-fp__row-code">{b.code}</span>
|
||||
<span className="kxp-fp__row-name">{b.name}</span>
|
||||
<span className="kxp-fp__row-cat">
|
||||
{b.status === 'pending' ? '준비 중' : b.category}
|
||||
</span>
|
||||
</span>
|
||||
{b.status === 'pending' ? (
|
||||
<span className="kxp-tag">준비중</span>
|
||||
) : (
|
||||
<IconChevronRight width={18} height={18} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{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={boothKey(b)}
|
||||
type="button"
|
||||
className={`kxp-fp__row${
|
||||
b.status === 'reserved' ? ' kxp-fp__row--reserved' : ''
|
||||
}${selected && boothKey(selected) === boothKey(b) ? ' is-active' : ''}`}
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
<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">
|
||||
{typeLabel(b.boothType)}
|
||||
{b.hallLabel ? ` · ${b.hallLabel}` : ''}
|
||||
</span>
|
||||
</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">
|
||||
|
||||
@ -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>
|
||||
|
||||
<div className="kxp-eventgrid">
|
||||
{SAMPLE_EVENTS.map((ev) => (
|
||||
<article
|
||||
key={ev.id}
|
||||
className="kxp-ecard"
|
||||
style={ev.accent === 'ai' ? { borderLeftColor: 'var(--color-ai-accent)' } : undefined}
|
||||
>
|
||||
<div className="kxp-ecard__media">
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background:
|
||||
ev.accent === 'ai'
|
||||
? 'linear-gradient(135deg, #6d4aff 0%, #1f29fc 100%)'
|
||||
: 'linear-gradient(135deg, #0066b3 0%, #004c86 100%)',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{ev.ai && (
|
||||
<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>
|
||||
</div>
|
||||
<div className="kxp-ecard__body">
|
||||
<div className="kxp-ecard__tags">
|
||||
{ev.tags.map((t) => (
|
||||
<span key={t} className="kxp-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="kxp-ecard__title">{ev.title}</h3>
|
||||
<div className="kxp-ecard__meta">
|
||||
<span>
|
||||
<IconCalendar width={18} height={18} /> {ev.dates}
|
||||
</span>
|
||||
<span>
|
||||
<IconPin width={18} height={18} /> {ev.hall}
|
||||
</span>
|
||||
</div>
|
||||
<a className="kxp-btn kxp-btn--primary kxp-btn--block" href="#events">
|
||||
사전등록
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</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">
|
||||
{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={accentAi ? { borderLeftColor: 'var(--color-ai-accent)' } : undefined}
|
||||
>
|
||||
<div className="kxp-ecard__media">
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: accentAi
|
||||
? 'linear-gradient(135deg, #6d4aff 0%, #1f29fc 100%)'
|
||||
: 'linear-gradient(135deg, #0066b3 0%, #004c86 100%)',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{accentAi && (
|
||||
<span className="kxp-ecard__topright kxp-chip kxp-chip--ai">
|
||||
<IconSparkles width={13} height={13} /> AI 최적화
|
||||
</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">
|
||||
<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.name}</h3>
|
||||
<div className="kxp-ecard__meta">
|
||||
<span>
|
||||
<IconCalendar width={18} height={18} /> {formatRange(ev.startDate, ev.endDate)}
|
||||
</span>
|
||||
<span>
|
||||
<IconPin width={18} height={18} /> {ev.hallLabel ?? '홀 배정 예정'}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
className="kxp-btn kxp-btn--primary kxp-btn--block"
|
||||
to={`/public/events/${encodeURIComponent(ev.id)}/register`}
|
||||
>
|
||||
사전등록
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
setSent(true);
|
||||
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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
124
src/frontend/src/screens/public/publicApi.ts
Normal file
124
src/frontend/src/screens/public/publicApi.ts
Normal 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),
|
||||
};
|
||||
57
src/frontend/src/screens/public/publicFormat.ts
Normal file
57
src/frontend/src/screens/public/publicFormat.ts
Normal 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 '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
}
|
||||
@ -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,99 +57,127 @@ 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>
|
||||
|
||||
<section className="kx-vis__kpis kx-vis__kpis--4" aria-label="리드 핵심 지표">
|
||||
{LEAD_KPIS.map((k) => (
|
||||
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
|
||||
<span className="kx-kpi__label">
|
||||
{k.label}
|
||||
{k.ai && (
|
||||
<span className="kx-kpi__ai" aria-hidden="true">
|
||||
<IconSpark size={12} />
|
||||
{!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="리드 핵심 지표">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
|
||||
<span className="kx-kpi__label">
|
||||
{k.label}
|
||||
{k.ai && (
|
||||
<span className="kx-kpi__ai" aria-hidden="true">
|
||||
<IconSpark size={12} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="kx-vis__lead-split">
|
||||
{/* 리드 테이블 */}
|
||||
<section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록">
|
||||
<div className="kx-vis__filters" role="group" aria-label="리드 필터">
|
||||
<span className="kx-vis__filters-label">필터</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-chip ${hotOnly ? 'is-active' : ''}`}
|
||||
aria-pressed={hotOnly}
|
||||
onClick={() => setHotOnly((v) => !v)}
|
||||
>
|
||||
스코어 80+ {hotOnly && <span aria-hidden="true">×</span>}
|
||||
</button>
|
||||
<span className="kx-vis__filters-count tnum">{rows.length}건</span>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">이름</th>
|
||||
<th scope="col">소속</th>
|
||||
<th scope="col">관심 제품</th>
|
||||
<th scope="col">관심도</th>
|
||||
<th scope="col">AI 스코어</th>
|
||||
<th scope="col">수집시각</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => {
|
||||
const isSel = l.id === selected?.id;
|
||||
return (
|
||||
<tr
|
||||
key={l.id}
|
||||
className={`kx-vis__lead-row ${isSel ? 'is-selected' : ''}`}
|
||||
onClick={() => setSelectedId(l.id)}
|
||||
tabIndex={0}
|
||||
aria-selected={isSel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedId(l.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="kx-vis__lead-name">{l.nameMasked}</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>
|
||||
<div className="kx-vis__lead-split">
|
||||
{/* 리드 테이블 */}
|
||||
<section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록">
|
||||
<div className="kx-vis__filters" role="group" aria-label="리드 필터">
|
||||
<span className="kx-vis__filters-label">필터</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-chip ${hotOnly ? 'is-active' : ''}`}
|
||||
aria-pressed={hotOnly}
|
||||
onClick={() => setHotOnly((v) => !v)}
|
||||
>
|
||||
스코어 80+ {hotOnly && <span aria-hidden="true">×</span>}
|
||||
</button>
|
||||
<span className="kx-vis__filters-count tnum">{rows.length}건</span>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">이름</th>
|
||||
<th scope="col">소속</th>
|
||||
<th scope="col">관심 제품</th>
|
||||
<th scope="col">관심도</th>
|
||||
<th scope="col">AI 스코어</th>
|
||||
<th scope="col">수집시각</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="kx-vis__table-foot">
|
||||
<span className="kx-vis__count">총 342건 중 {rows.length}건 표시</span>
|
||||
</div>
|
||||
</section>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => {
|
||||
const isSel = l.id === selected?.id;
|
||||
return (
|
||||
<tr
|
||||
key={l.id}
|
||||
className={`kx-vis__lead-row ${isSel ? 'is-selected' : ''}`}
|
||||
onClick={() => setSelectedId(l.id)}
|
||||
tabIndex={0}
|
||||
aria-selected={isSel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedId(l.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="kx-vis__lead-name">{l.nameMasked}</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>
|
||||
</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">총 {total.toLocaleString()}건 중 {rows.length}건 표시</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 리드 상세·팔로업 */}
|
||||
{selected && <LeadDetail lead={selected} />}
|
||||
</div>
|
||||
{/* 리드 상세·팔로업 */}
|
||||
{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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,204 +68,240 @@ 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>
|
||||
|
||||
{/* KPI 밴드 */}
|
||||
<section className="kx-vis__kpis" aria-label="관람객 핵심 지표">
|
||||
{REG_KPIS.map((k) => (
|
||||
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
|
||||
<span className="kx-kpi__label">
|
||||
{k.label}
|
||||
{k.ai && (
|
||||
<span className="kx-kpi__ai" aria-hidden="true">
|
||||
<IconSpark size={12} />
|
||||
{!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="관람객 핵심 지표">
|
||||
{summary.kpis.map((k) => (
|
||||
<div key={k.label} className={`kx-kpi ${k.ai ? 'kx-kpi--ai' : ''}`}>
|
||||
<span className="kx-kpi__label">
|
||||
{k.label}
|
||||
{k.ai && (
|
||||
<span className="kx-kpi__ai" aria-hidden="true">
|
||||
<IconSpark size={12} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.delta && (
|
||||
<span className={`kx-delta kx-delta--${k.trend === 'down' ? 'down' : 'up'}`}>
|
||||
{k.trend === 'down' ? '▼' : '▲'} {k.delta}
|
||||
</span>
|
||||
)}
|
||||
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 추이(2/3) + 폼·배지(1/3) */}
|
||||
<div className="kx-vis__split">
|
||||
<section className="kx-card kx-vis__trend" aria-label="등록·체크인 추이">
|
||||
<div className="kx-card__head">
|
||||
<h2>등록 · 체크인 추이</h2>
|
||||
<ul className="kx-vis__legend" aria-hidden="true">
|
||||
<li>
|
||||
<span className="kx-dot" style={{ background: CHART.primary600 }} /> 사전등록
|
||||
</li>
|
||||
<li>
|
||||
<span className="kx-dot" style={{ background: CHART.slate }} /> 체크인
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="kx-card__hint">개장 D-14부터 당일까지 누적 사전등록·체크인 인원입니다.</p>
|
||||
<div className="kx-vis__chart">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={REG_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} />
|
||||
<stop offset="100%" stopColor={CHART.primary600} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gCheckIn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART.slate} stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor={CHART.slate} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</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)}천`} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, name) => [`${(value as number).toLocaleString()}명`, name]}
|
||||
/>
|
||||
<Area type="monotone" dataKey="preReg" name="사전등록" stroke={CHART.primary600} fill="url(#gPreReg)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="checkIn" name="체크인" stroke={CHART.slate} fill="url(#gCheckIn)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="kx-vis__donut-row">
|
||||
<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) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(value, name) => [`${value}%`, name]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="kx-vis__donut-legend">
|
||||
<p className="kx-vis__donut-title">등록 유형 분포</p>
|
||||
<ul>
|
||||
{REG_TYPES.map((s) => (
|
||||
<li key={s.name}>
|
||||
<span className="kx-dot" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
<strong className="tnum">{s.value}%</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="kx-vis__aside">
|
||||
<section className="kx-card" aria-label="등록 폼 관리">
|
||||
<div className="kx-card__head">
|
||||
<h2>등록 폼 관리</h2>
|
||||
</div>
|
||||
<ul className="kx-vis__forms">
|
||||
{REG_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'}`} />
|
||||
{f.name}
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.delta && (
|
||||
<span className={`kx-delta kx-delta--${k.trend === 'down' ? 'down' : 'up'}`}>
|
||||
{k.trend === 'down' ? '▼' : '▲'} {k.delta}
|
||||
</span>
|
||||
<span className="kx-vis__form-count tnum">{f.count.toLocaleString()}명</span>
|
||||
<span className={`kx-vis__form-state ${f.active ? 'is-on' : ''}`}>
|
||||
{f.active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="ai" block leadingIcon={<IconSpark size={16} />}>
|
||||
AI 폼빌더 시작
|
||||
</Button>
|
||||
<p className="kx-vis__ai-note">
|
||||
<AiLabel>AI 폼빌더</AiLabel> 세그먼트 성향에 맞춘 등록 문항을 자동 제안합니다.
|
||||
</p>
|
||||
)}
|
||||
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="kx-card" aria-label="배지 템플릿">
|
||||
<div className="kx-card__head">
|
||||
<h2>배지 템플릿</h2>
|
||||
</div>
|
||||
<div className="kx-vis__badge-preview">
|
||||
<div className="kx-badgecard">
|
||||
<div className="kx-badgecard__top">KINTEX VISITOR</div>
|
||||
<div className="kx-badgecard__qr" aria-hidden="true">
|
||||
<QrGlyph />
|
||||
{/* 추이(2/3) + 폼·배지(1/3) */}
|
||||
<div className="kx-vis__split">
|
||||
<section className="kx-card kx-vis__trend" aria-label="등록·체크인 추이">
|
||||
<div className="kx-card__head">
|
||||
<h2>등록 · 체크인 추이</h2>
|
||||
<ul className="kx-vis__legend" aria-hidden="true">
|
||||
<li>
|
||||
<span className="kx-dot" style={{ background: CHART.primary600 }} /> 사전등록
|
||||
</li>
|
||||
<li>
|
||||
<span className="kx-dot" style={{ background: CHART.slate }} /> 체크인
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="kx-card__hint">누적 사전등록·체크인 인원입니다.</p>
|
||||
<div className="kx-vis__chart">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<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} />
|
||||
<stop offset="100%" stopColor={CHART.primary600} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gCheckIn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART.slate} stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor={CHART.slate} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</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) => (v as number >= 1000 ? `${Math.round((v as number) / 1000)}천` : String(v))} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, name) => [`${(value as number).toLocaleString()}명`, name]}
|
||||
/>
|
||||
<Area type="monotone" dataKey="preReg" name="사전등록" stroke={CHART.primary600} fill="url(#gPreReg)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="checkIn" name="체크인" stroke={CHART.slate} fill="url(#gCheckIn)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="kx-vis__donut-row">
|
||||
<div className="kx-vis__donut">
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<PieChart>
|
||||
<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>
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(value, name) => [`${value}%`, name]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="kx-badgecard__name">김 **</p>
|
||||
<p className="kx-badgecard__org">Samsung Electronics</p>
|
||||
<div className="kx-badgecard__foot">
|
||||
<span className="kx-badgecard__tag">BUYER</span>
|
||||
<span className="kx-badgecard__no tnum">2026-V01</span>
|
||||
<div className="kx-vis__donut-legend">
|
||||
<p className="kx-vis__donut-title">등록 유형 분포</p>
|
||||
<ul>
|
||||
{summary.types.map((s) => (
|
||||
<li key={s.name}>
|
||||
<span className="kx-dot" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
<strong className="tnum">{s.value}%</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" block>배지 편집</Button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 등록자 명단 */}
|
||||
<section className="kx-card kx-vis__table-card" aria-label="전체 신청자 명단">
|
||||
<div className="kx-card__head">
|
||||
<h2>전체 신청자 명단</h2>
|
||||
<span className="kx-vis__pii-note">개인정보 보호 · 이름 마스킹 표기</span>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">이름</th>
|
||||
<th scope="col">유형</th>
|
||||
<th scope="col">소속</th>
|
||||
<th scope="col">등록일</th>
|
||||
<th scope="col">체크인</th>
|
||||
<th scope="col">배지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{REGISTRANTS.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<span className="kx-vis__person">
|
||||
<span className="kx-vis__avatar" aria-hidden="true">{r.initial}</span>
|
||||
{r.nameMasked}
|
||||
</span>
|
||||
</td>
|
||||
<td><TypePill type={r.type} /></td>
|
||||
<td>{r.company}</td>
|
||||
<td className="tnum">{r.registeredAt}</td>
|
||||
<td><CheckinPill state={r.checkin} /></td>
|
||||
<td>
|
||||
{r.badgeIssued ? (
|
||||
<span className="kx-vis__badge-issued" title="배지 발급 완료">
|
||||
<QrGlyph size={16} /> 발급
|
||||
<aside className="kx-vis__aside">
|
||||
<section className="kx-card" aria-label="등록 폼 관리">
|
||||
<div className="kx-card__head">
|
||||
<h2>등록 폼 관리</h2>
|
||||
</div>
|
||||
<ul className="kx-vis__forms">
|
||||
{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'}`} />
|
||||
{f.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="kx-vis__badge-none">미발급</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="kx-vis__table-foot">
|
||||
<span className="kx-vis__count">총 12,480명 중 1–6 표시</span>
|
||||
</div>
|
||||
</section>
|
||||
<span className="kx-vis__form-count tnum">{f.count.toLocaleString()}명</span>
|
||||
<span className={`kx-vis__form-state ${f.active ? 'is-on' : ''}`}>
|
||||
{f.active ? '활성' : '비활성'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="ai" block leadingIcon={<IconSpark size={16} />}>
|
||||
AI 폼빌더 시작
|
||||
</Button>
|
||||
<p className="kx-vis__ai-note">
|
||||
<AiLabel>AI 폼빌더</AiLabel> 세그먼트 성향에 맞춘 등록 문항을 자동 제안합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="kx-card" aria-label="배지 템플릿">
|
||||
<div className="kx-card__head">
|
||||
<h2>배지 템플릿</h2>
|
||||
</div>
|
||||
<div className="kx-vis__badge-preview">
|
||||
<div className="kx-badgecard">
|
||||
<div className="kx-badgecard__top">KINTEX VISITOR</div>
|
||||
<div className="kx-badgecard__qr" aria-hidden="true">
|
||||
<QrGlyph />
|
||||
</div>
|
||||
<p className="kx-badgecard__name">김 **</p>
|
||||
<p className="kx-badgecard__org">Samsung Electronics</p>
|
||||
<div className="kx-badgecard__foot">
|
||||
<span className="kx-badgecard__tag">BUYER</span>
|
||||
<span className="kx-badgecard__no tnum">2026-V01</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="kx-vis__ai-note">배지 미리보기는 마스킹 표기 템플릿입니다(실 데이터 아님).</p>
|
||||
<Button variant="secondary" block>배지 편집</Button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* 등록자 명단 */}
|
||||
<section className="kx-card kx-vis__table-card" aria-label="전체 신청자 명단">
|
||||
<div className="kx-card__head">
|
||||
<h2>전체 신청자 명단</h2>
|
||||
<span className="kx-vis__pii-note">개인정보 보호 · 이름 마스킹 표기</span>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">이름</th>
|
||||
<th scope="col">유형</th>
|
||||
<th scope="col">소속</th>
|
||||
<th scope="col">등록일</th>
|
||||
<th scope="col">체크인</th>
|
||||
<th scope="col">배지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<span className="kx-vis__person">
|
||||
<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><CheckinPill state={r.checkin} /></td>
|
||||
<td>
|
||||
{r.badgeIssued ? (
|
||||
<span className="kx-vis__badge-issued" title="배지 발급 완료">
|
||||
<QrGlyph size={16} /> 발급
|
||||
</span>
|
||||
) : (
|
||||
<span className="kx-vis__badge-none">미발급</span>
|
||||
)}
|
||||
</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">
|
||||
총 {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 (
|
||||
|
||||
@ -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;
|
||||
|
||||
96
src/frontend/src/screens/visitor/visitorApi.ts
Normal file
96
src/frontend/src/screens/visitor/visitorApi.ts
Normal 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()}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user