feat(domains): second-wave hardening — auction scoring, visitor check-in/lead engine, CMS versions/media/public (V21)
- M15 auction: scoring/quotation calc + unit tests, screens/auction local API as canonical client - M10 visitor: check-in desk (SCR-31, /visitors/checkin route+nav), QR token + checkin_at, lead exhibitor/consent/note, check-in/search/stats/badge-reissue/lead-create/CSV export (masked PII only, RBAC-guarded, @Audited) + rule-based lead scoring engine - M17 CMS: content version history, media library, public CMS API (/api/public/cms), HtmlSanitizer - Flyway repair: extensions appended to already-applied V17/V19 split into V21 (checksum-safe), V17/V19 restored - verified: compileJava, test, tsc -b all EXIT 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2854f068ed
commit
c6f3a8d916
@ -96,4 +96,5 @@
|
||||
| 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) |
|
||||
| 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` |
|
||||
| 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 |
|
||||
| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |
|
||||
| 2026-07-12 | **배포·핫픽스 3건 + 풀 E2E 라이브 검증 완료** — 1차 push는 서버 `tsc -b` 실패로 프론트 미배포(로컬 `--noEmit`과 설정 차이): ①toast 미렌더+TS6133·recharts formatter 타입 수정(8509787) ②레거시 `/api/auth/login`이 잠금·OTP 강제를 우회하던 보안 구멍 → secureLogin 위임(a657f95) ③**PG 별칭 소문자 폴딩으로 Map 매퍼 22파일·191별칭 전부 런타임 null이던 결함 → `AS "alias"` 일괄 교정(89bf2ce)** — login-slides imageUrl null(01:08부터)·secure 로그인 전멸이 이것. E2E 확인: secure 로그인 admin=OTP_ENROLL+챌린지(시크릿 미누출)·레거시=401 차단·visitor 가입→로그인→카탈로그 78건 실데이터·admin API 403 RBAC·Flyway V12/V13 적용·신규 번들 라이브. design.md v2.1.1(Stitch 84화면 상태 정합·designer 경유) |
|
||||
|
||||
@ -64,6 +64,15 @@ public class AuctionController {
|
||||
return ApiResponse.ok(service.placeBid(id, principal, req));
|
||||
}
|
||||
|
||||
/** POST /api/auctions/{id}/close — 라운드 마감 처리(발주자만). 마감 후 봉인 해제·응찰 차단. */
|
||||
@Audited(action = "AUCTION_CLOSE", targetType = "auction")
|
||||
@PostMapping("/{id}/close")
|
||||
public ApiResponse<AuctionSummary> close(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.closeRound(id, principal));
|
||||
}
|
||||
|
||||
/** POST /api/auctions/{id}/award — 낙찰(발주자·마감 후만). 사유 필수·감사 추적. */
|
||||
@Audited(action = "AUCTION_AWARD", targetType = "award")
|
||||
@PostMapping("/{id}/award")
|
||||
|
||||
@ -151,6 +151,10 @@ public interface AuctionMapper {
|
||||
@Select("SELECT auction_id FROM bid WHERE id = #{bidId}")
|
||||
String findAuctionIdOfBid(@Param("bidId") String bidId);
|
||||
|
||||
/** 라운드 마감 처리 — deadline 을 현재 시각으로 당겨 마감(closed) 상태로 전이. */
|
||||
@Update("UPDATE auction SET deadline = now() WHERE id = #{id} AND deadline > now()")
|
||||
int closeAuction(@Param("id") String id);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO award (id, auction_id, bid_id, reason, awarded_by)
|
||||
VALUES (#{id}, #{auctionId}, #{bidId}, #{reason}, #{awardedBy})
|
||||
|
||||
@ -141,14 +141,13 @@ public class AuctionService {
|
||||
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);
|
||||
// ★ 서버 재계산(신뢰 경계): 라인아이템이 있으면 소계=Σ(수량×단가), 없으면 total 폴백.
|
||||
// 음수 수량·단가, 소계 0 이하는 QuotationCalc 가 검증(400). 산식은 QuotationCalcTest 로 고정.
|
||||
QuotationCalc.Totals t = hasLines
|
||||
? QuotationCalc.fromLines(req.lines())
|
||||
: QuotationCalc.fromTotal(req.total());
|
||||
long subtotal = t.subtotal();
|
||||
long vat = t.vat();
|
||||
long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계
|
||||
int leadDays = req.leadDays() == null ? 0 : req.leadDays();
|
||||
String linesJson = hasLines ? toJson(req.lines()) : null;
|
||||
@ -182,7 +181,29 @@ public class AuctionService {
|
||||
|
||||
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);
|
||||
return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat,
|
||||
version, myRank, lowest);
|
||||
}
|
||||
|
||||
// ── 라운드 마감 처리(발주자) ────────────────────────────────────────────────
|
||||
/**
|
||||
* 현재 라운드를 즉시 마감(deadline=now)한다. 발주자만. 마감 후 봉인이 해제되어
|
||||
* 발주자는 award-view 로 전 견적을 비교하고 낙찰할 수 있다. 응찰은 마감 후 409로 차단된다.
|
||||
*/
|
||||
@Transactional
|
||||
public AuctionSummary closeRound(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.CONFLICT); // 이미 마감됨
|
||||
}
|
||||
mapper.closeAuction(id);
|
||||
return toSummary(mapper.findAuction(id));
|
||||
}
|
||||
|
||||
// ── 낙찰(Award) ────────────────────────────────────────────────────────────
|
||||
@ -254,17 +275,8 @@ public class AuctionService {
|
||||
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);
|
||||
// 종합점수 산식은 AuctionScoring(순수 함수)로 위임 — AuctionScoringTest 로 고정.
|
||||
scores[i] = AuctionScoring.score(criteria, w, total, minTotal, lead, minLead, rating);
|
||||
if (scores[i] > bestScore) {
|
||||
bestScore = scores[i];
|
||||
bestIdx = i;
|
||||
@ -526,4 +538,15 @@ public class AuctionService {
|
||||
private static double round1(double v) {
|
||||
return Math.round(v * 10.0) / 10.0;
|
||||
}
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private static String toJson(Object o) {
|
||||
try {
|
||||
return JSON.writeValueAsString(o);
|
||||
} catch (Exception e) {
|
||||
return null; // 직렬화 실패 시 라인아이템 스냅샷 생략(집계는 컬럼값 사용)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,10 +4,13 @@ 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.CmsContentUpdateRequest;
|
||||
import com.zioinfo.kintex.cms.dto.CmsContentVersionDto;
|
||||
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 com.zioinfo.kintex.common.audit.Audited;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -18,8 +21,10 @@ import java.util.List;
|
||||
* M17 CMS 콘텐츠 API (SCR-35·37). 인증 필수.
|
||||
* <ul>
|
||||
* <li>GET/POST /api/cms/contents — 목록/신규(초안)</li>
|
||||
* <li>GET/PUT /api/cms/contents/{id} — 상세/본문 저장(sanitize·버전 스냅샷)</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>
|
||||
* <li>GET /api/cms/contents/{id}/versions · POST …/rollback?versionNo= — 버전 이력/롤백(매니저↑)</li>
|
||||
* <li>GET/PUT /api/cms/contents/{id}/translations · POST …/translations/ai?lang= — 번역 upsert / AI 초벌(501)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@ -54,7 +59,25 @@ public class CmsContentController {
|
||||
return ApiResponse.ok(service.create(principal, req));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<CmsContentDto> get(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.get(id));
|
||||
}
|
||||
|
||||
/** 본문/제목/예약 저장 — sanitize(XSS N2) + 버전 스냅샷. */
|
||||
@Audited(action = "CMS_CONTENT_EDIT", targetType = "cms_content")
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<CmsContentDto> update(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody CmsContentUpdateRequest req) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.update(principal, id, req));
|
||||
}
|
||||
|
||||
/** 게시 상태 전이. value=draft|review|approved|published(전진만). 역전이/무효 → 400. */
|
||||
@Audited(action = "CMS_CONTENT_TRANSITION", targetType = "cms_content")
|
||||
@PatchMapping("/{id}/status")
|
||||
public ApiResponse<CmsContentDto> transition(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@ -63,6 +86,25 @@ public class CmsContentController {
|
||||
return ApiResponse.ok(service.transition(principal, id, value));
|
||||
}
|
||||
|
||||
/** 버전 이력(최신순). */
|
||||
@GetMapping("/{id}/versions")
|
||||
public ApiResponse<List<CmsContentVersionDto>> versions(
|
||||
@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.versions(id));
|
||||
}
|
||||
|
||||
/** 특정 버전으로 롤백(매니저↑). 복원 후 롤백 스냅샷을 새 버전으로 남긴다. */
|
||||
@Audited(action = "CMS_CONTENT_ROLLBACK", targetType = "cms_content")
|
||||
@PostMapping("/{id}/rollback")
|
||||
public ApiResponse<CmsContentDto> rollback(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestParam int versionNo) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.rollback(principal, id, versionNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/translations")
|
||||
public ApiResponse<List<CmsTranslationDto>> translations(
|
||||
@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@ -79,4 +121,17 @@ public class CmsContentController {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.saveTranslation(id, req));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 자동 번역(초벌) — 인터페이스만. AiTextRouter 미배선 시 501 NOT_IMPLEMENTED.
|
||||
* 프론트(SCR-37)는 501을 degraded("준비 중")로 처리한다.
|
||||
*/
|
||||
@PostMapping("/{id}/translations/ai")
|
||||
public ApiResponse<CmsTranslationDto> aiTranslate(
|
||||
@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestParam String lang) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.aiTranslate(id, lang));
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,107 @@ public interface CmsMapper {
|
||||
""")
|
||||
int updateStatus(@Param("id") String id, @Param("status") String status);
|
||||
|
||||
/** 본문/제목/예약·플래그 수정(sanitize 는 서비스). scheduledAt 은 ISO 문자열 또는 null. */
|
||||
@Update("""
|
||||
UPDATE cms_content
|
||||
SET title = #{title},
|
||||
body = #{body},
|
||||
scheduled_at = CAST(#{scheduledAt} AS timestamptz),
|
||||
signage = COALESCE(#{signage}, signage),
|
||||
mailing = COALESCE(#{mailing}, mailing),
|
||||
updated_at = now()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int updateContent(Map<String, Object> p);
|
||||
|
||||
/** 롤백 — 특정 버전의 제목/본문/상태로 복원(published 는 published_at 유지). */
|
||||
@Update("""
|
||||
UPDATE cms_content
|
||||
SET title = #{title},
|
||||
body = #{body},
|
||||
status = #{status},
|
||||
updated_at = now()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int restoreContent(Map<String, Object> p);
|
||||
|
||||
// ── 버전 이력 ────────────────────────────────────────────────────────────
|
||||
@Select("SELECT COALESCE(MAX(version_no),0) FROM cms_content_version WHERE content_id = #{contentId}")
|
||||
int maxVersionNo(@Param("contentId") String contentId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO cms_content_version (id, content_id, version_no, title, body, status, author_name, note)
|
||||
VALUES (#{id}, #{contentId}, #{versionNo}, #{title}, #{body}, #{status}, #{authorName}, #{note})
|
||||
""")
|
||||
int insertVersion(Map<String, Object> p);
|
||||
|
||||
@Select("""
|
||||
SELECT id, content_id AS "contentId", version_no AS "versionNo", title, body, status,
|
||||
author_name AS "authorName", note,
|
||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
|
||||
FROM cms_content_version
|
||||
WHERE content_id = #{contentId}
|
||||
ORDER BY version_no DESC
|
||||
""")
|
||||
List<Map<String, Object>> findVersions(@Param("contentId") String contentId);
|
||||
|
||||
@Select("""
|
||||
SELECT id, content_id AS "contentId", version_no AS "versionNo", title, body, status,
|
||||
author_name AS "authorName", note,
|
||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
|
||||
FROM cms_content_version
|
||||
WHERE content_id = #{contentId} AND version_no = #{versionNo}
|
||||
""")
|
||||
Map<String, Object> findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo);
|
||||
|
||||
// ── 예약 게시(도달분 자동 게시) ────────────────────────────────────────────
|
||||
/** scheduled_at 이 now 도달·미게시(approved 이하)인 콘텐츠를 published 로 승격. 반환=처리 건수. */
|
||||
@Update("""
|
||||
UPDATE cms_content
|
||||
SET status = 'published', published_at = now(), updated_at = now()
|
||||
WHERE scheduled_at IS NOT NULL
|
||||
AND scheduled_at <= now()
|
||||
AND status <> 'published'
|
||||
""")
|
||||
int publishDueScheduled();
|
||||
|
||||
// ── 미디어 라이브러리 ──────────────────────────────────────────────────────
|
||||
@Insert("""
|
||||
INSERT INTO cms_media (id, event_id, file_name, url, content_type, size_bytes, alt_text, uploader_name)
|
||||
VALUES (#{id}, #{eventId}, #{fileName}, #{url}, #{contentType}, #{sizeBytes}, #{altText}, #{uploaderName})
|
||||
""")
|
||||
int insertMedia(Map<String, Object> p);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT id, event_id AS "eventId", file_name AS "fileName", url, content_type AS "contentType",
|
||||
size_bytes AS "sizeBytes", alt_text AS "altText", uploader_name AS "uploaderName",
|
||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
|
||||
FROM cms_media
|
||||
<where>
|
||||
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
|
||||
</where>
|
||||
ORDER BY created_at DESC
|
||||
LIMIT #{limit}
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> findMedia(Map<String, Object> q);
|
||||
|
||||
// ── 공개 조회(게시 상태만) ─────────────────────────────────────────────────
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
|
||||
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 cms_content
|
||||
WHERE status = 'published' AND content_type = #{type}
|
||||
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
|
||||
ORDER BY published_at DESC NULLS LAST, updated_at DESC
|
||||
LIMIT #{limit}
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> findPublishedByType(Map<String, Object> q);
|
||||
|
||||
// ── 번역 ─────────────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT content_id AS "contentId", lang, title, body, trans_status AS "transStatus",
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package com.zioinfo.kintex.cms;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.cms.dto.CmsMediaDto;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.common.audit.Audited;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* M17 CMS 미디어 라이브러리 API (SCR-35 중앙 미디어 스트립). 인증 필수.
|
||||
* 업로드는 기존 패턴(login-slides) 재사용 — 서버가 UUID 파일명 강제, /uploads/cms/** 로 서빙.
|
||||
* <ul>
|
||||
* <li>GET /api/cms/media?eventId=&limit= — 최신순 목록</li>
|
||||
* <li>POST /api/cms/media (multipart: file[+eventId,altText]) — 업로드</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cms/media")
|
||||
public class CmsMediaController {
|
||||
|
||||
private final CmsService service;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public CmsMediaController(CmsService service, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<CmsMediaDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestParam(required = false) String eventId,
|
||||
@RequestParam(defaultValue = "60") int limit) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.mediaList(eventId, limit));
|
||||
}
|
||||
|
||||
@Audited(action = "CMS_MEDIA_UPLOAD", targetType = "cms_media")
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<CmsMediaDto> upload(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestParam(value = "eventId", required = false) String eventId,
|
||||
@RequestParam(value = "altText", required = false) String altText) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.addMedia(principal, file, eventId, altText));
|
||||
}
|
||||
}
|
||||
@ -1,41 +1,61 @@
|
||||
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.cms.dto.*;
|
||||
import com.zioinfo.kintex.common.PageResponse;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.common.text.HtmlSanitizer;
|
||||
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* M17 CMS 서비스 — 콘텐츠 게시 워크플로 + 다국어 번역.
|
||||
* M17 CMS 서비스 — 콘텐츠 게시 워크플로 + 버전 이력/롤백 + 미디어 + 다국어 + 예약 게시 + 공개 조회.
|
||||
* <p>상태 전이: draft(0)→review(1)→approved(2)→published(3) 전진만 허용. 역전이/동일 전이 → 400(VALIDATION).
|
||||
* approved·published 전이는 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합).
|
||||
* approved·published 전이와 롤백은 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합).
|
||||
* <p>본문 저장 시 {@link HtmlSanitizer}로 XSS 방어(N2). AI 자동 번역은 인터페이스만(501, AiTextRouter 배선 대기).
|
||||
*/
|
||||
@Service
|
||||
public class CmsService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CmsService.class);
|
||||
|
||||
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 static final long MAX_MEDIA_BYTES = 8L * 1024 * 1024; // 8MB
|
||||
private static final Map<String, String> ALLOWED_TYPES = Map.of(
|
||||
"image/png", "png", "image/jpeg", "jpg", "image/webp", "webp", "image/gif", "gif");
|
||||
private static final Set<String> ALLOWED_EXTS = Set.of("png", "jpg", "jpeg", "webp", "gif");
|
||||
|
||||
private final CmsMapper mapper;
|
||||
private final SystemAccessGuard scope;
|
||||
private final Path uploadRoot;
|
||||
|
||||
public CmsService(CmsMapper mapper, SystemAccessGuard scope) {
|
||||
public CmsService(CmsMapper mapper, SystemAccessGuard scope,
|
||||
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
this.mapper = mapper;
|
||||
this.scope = scope;
|
||||
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
// ── 콘텐츠 목록/조회 ───────────────────────────────────────────────────────
|
||||
public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
|
||||
int page, int size) {
|
||||
runScheduledPublish();
|
||||
int p = Math.max(page, 0);
|
||||
int s = size <= 0 ? 50 : Math.min(size, 200);
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
@ -66,11 +86,33 @@ public class CmsService {
|
||||
pm.put("eventId", blankToNull(req.eventId()));
|
||||
pm.put("contentType", blankToNull(req.contentType()));
|
||||
pm.put("title", req.title());
|
||||
pm.put("body", req.body());
|
||||
pm.put("body", HtmlSanitizer.sanitize(req.body()));
|
||||
pm.put("lang", blankToNull(req.lang()));
|
||||
pm.put("authorId", principal.userId());
|
||||
pm.put("authorName", principal.displayName());
|
||||
mapper.insertContent(pm);
|
||||
snapshot(id, "created", principal.displayName());
|
||||
return get(id);
|
||||
}
|
||||
|
||||
/** 본문/제목/예약 저장(SCR-35 에디터). sanitize + 저장 후 버전 스냅샷. */
|
||||
@Transactional
|
||||
public CmsContentDto update(KintexPrincipal principal, String id, CmsContentUpdateRequest req) {
|
||||
if (mapper.findContentById(id) == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
if (req.title() == null || req.title().isBlank()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "제목을 입력해 주세요.");
|
||||
}
|
||||
Map<String, Object> pm = new HashMap<>();
|
||||
pm.put("id", id);
|
||||
pm.put("title", req.title().trim());
|
||||
pm.put("body", HtmlSanitizer.sanitize(req.body()));
|
||||
pm.put("scheduledAt", blankToNull(req.scheduledAt()));
|
||||
pm.put("signage", req.signage());
|
||||
pm.put("mailing", req.mailing());
|
||||
mapper.updateContent(pm);
|
||||
snapshot(id, "edited", principal.displayName());
|
||||
return get(id);
|
||||
}
|
||||
|
||||
@ -97,9 +139,60 @@ public class CmsService {
|
||||
scope.requireManager(principal);
|
||||
}
|
||||
mapper.updateStatus(id, to);
|
||||
if ("published".equals(to)) {
|
||||
snapshot(id, "published", principal.displayName());
|
||||
}
|
||||
return get(id);
|
||||
}
|
||||
|
||||
// ── 버전 이력/롤백 ─────────────────────────────────────────────────────────
|
||||
public List<CmsContentVersionDto> versions(String contentId) {
|
||||
if (mapper.findContentById(contentId) == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
return mapper.findVersions(contentId).stream().map(CmsService::toVersionDto).toList();
|
||||
}
|
||||
|
||||
/** 특정 버전으로 롤백(매니저 이상). 복원 후 롤백 스냅샷을 새 버전으로 남긴다. */
|
||||
@Transactional
|
||||
public CmsContentDto rollback(KintexPrincipal principal, String contentId, int versionNo) {
|
||||
scope.requireManager(principal);
|
||||
if (mapper.findContentById(contentId) == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
Map<String, Object> v = mapper.findVersion(contentId, versionNo);
|
||||
if (v == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "해당 버전을 찾을 수 없습니다: v" + versionNo);
|
||||
}
|
||||
Map<String, Object> pm = new HashMap<>();
|
||||
pm.put("id", contentId);
|
||||
pm.put("title", str(v.get("title")));
|
||||
pm.put("body", str(v.get("body")));
|
||||
pm.put("status", str(v.get("status")));
|
||||
mapper.restoreContent(pm);
|
||||
snapshot(contentId, "rollback-from-v" + versionNo, principal.displayName());
|
||||
return get(contentId);
|
||||
}
|
||||
|
||||
/** 현재 콘텐츠 상태를 버전 스냅샷으로 보존(단조 증가). */
|
||||
private void snapshot(String contentId, String note, String authorName) {
|
||||
Map<String, Object> cur = mapper.findContentById(contentId);
|
||||
if (cur == null) {
|
||||
return;
|
||||
}
|
||||
int next = mapper.maxVersionNo(contentId) + 1;
|
||||
Map<String, Object> vp = new HashMap<>();
|
||||
vp.put("id", "cv-" + UUID.randomUUID().toString().substring(0, 12));
|
||||
vp.put("contentId", contentId);
|
||||
vp.put("versionNo", next);
|
||||
vp.put("title", str(cur.get("title")));
|
||||
vp.put("body", str(cur.get("body")));
|
||||
vp.put("status", str(cur.get("status")));
|
||||
vp.put("authorName", authorName);
|
||||
vp.put("note", note);
|
||||
mapper.insertVersion(vp);
|
||||
}
|
||||
|
||||
// ── 번역 ─────────────────────────────────────────────────────────────────
|
||||
public List<CmsTranslationDto> translations(String contentId) {
|
||||
if (mapper.findContentById(contentId) == null) {
|
||||
@ -126,12 +219,98 @@ public class CmsService {
|
||||
pm.put("contentId", contentId);
|
||||
pm.put("lang", lang);
|
||||
pm.put("title", req.title());
|
||||
pm.put("body", req.body());
|
||||
pm.put("body", HtmlSanitizer.sanitize(req.body()));
|
||||
pm.put("transStatus", st);
|
||||
mapper.upsertTranslation(pm);
|
||||
return translations(contentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 자동 번역 — 인터페이스만(AiTextRouter/Claude 배선 지점). 미설정 시 501 NOT_IMPLEMENTED.
|
||||
* <p>프론트는 501을 degraded("준비 중")로 처리한다. 배선되면 이 지점에서 원문→대상언어 초벌(trans_status=ai)을 upsert.
|
||||
*/
|
||||
public CmsTranslationDto aiTranslate(String contentId, String lang) {
|
||||
if (mapper.findContentById(contentId) == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
if (!LANGS.contains(lang == null ? "" : lang.trim())) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 언어입니다: " + lang);
|
||||
}
|
||||
// AiTextRouter 미배선 — 표준 501(§ AI 설정형 전환 이전).
|
||||
throw new ApiException(ErrorCode.NOT_IMPLEMENTED,
|
||||
"AI 자동 번역은 아직 배선되지 않았습니다(AiTextRouter/Claude 연동 대기).");
|
||||
}
|
||||
|
||||
// ── 미디어 라이브러리 ──────────────────────────────────────────────────────
|
||||
public List<CmsMediaDto> mediaList(String eventId, int limit) {
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("eventId", blankToNull(eventId));
|
||||
q.put("limit", limit <= 0 ? 60 : Math.min(limit, 200));
|
||||
return mapper.findMedia(q).stream().map(CmsService::toMediaDto).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CmsMediaDto addMedia(KintexPrincipal principal, MultipartFile file, String eventId, String altText) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "업로드할 이미지를 첨부해 주세요.");
|
||||
}
|
||||
if (file.getSize() > MAX_MEDIA_BYTES) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "이미지는 8MB 이하만 업로드할 수 있습니다.");
|
||||
}
|
||||
String ext = resolveExtension(file);
|
||||
String fileName = UUID.randomUUID() + "." + ext;
|
||||
try {
|
||||
Path dir = uploadRoot.resolve("cms");
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(fileName).normalize();
|
||||
if (!target.startsWith(uploadRoot)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "잘못된 파일 경로입니다.");
|
||||
}
|
||||
try (var in = file.getInputStream()) {
|
||||
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("cms media store failed: {}", e.getMessage());
|
||||
throw new ApiException(ErrorCode.INTERNAL, "이미지 저장에 실패했습니다.");
|
||||
}
|
||||
String id = "cm-" + UUID.randomUUID().toString().substring(0, 12);
|
||||
String display = safeDisplayName(file.getOriginalFilename(), ext);
|
||||
Map<String, Object> pm = new HashMap<>();
|
||||
pm.put("id", id);
|
||||
pm.put("eventId", blankToNull(eventId));
|
||||
pm.put("fileName", display);
|
||||
pm.put("url", "/uploads/cms/" + fileName);
|
||||
pm.put("contentType", file.getContentType());
|
||||
pm.put("sizeBytes", file.getSize());
|
||||
pm.put("altText", blankToNull(altText));
|
||||
pm.put("uploaderName", principal.displayName());
|
||||
mapper.insertMedia(pm);
|
||||
return mediaList(eventId, 200).stream().filter(m -> m.id().equals(id)).findFirst()
|
||||
.orElseThrow(() -> new ApiException(ErrorCode.INTERNAL));
|
||||
}
|
||||
|
||||
// ── 예약 게시 / 공개 조회 ──────────────────────────────────────────────────
|
||||
/** 예약 시각 도달분 자동 게시(조회 시 지연 처리). 반환=이번에 게시된 건수. */
|
||||
@Transactional
|
||||
public int runScheduledPublish() {
|
||||
try {
|
||||
return mapper.publishDueScheduled();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("scheduled publish sweep skipped: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 공개 조회(무인증) — 게시 상태만. 조회 시 예약 도달분 자동 게시 후 반환. */
|
||||
public List<CmsContentDto> publicByType(String type, String eventId, int limit) {
|
||||
runScheduledPublish();
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT));
|
||||
q.put("eventId", blankToNull(eventId));
|
||||
q.put("limit", limit <= 0 ? 50 : Math.min(limit, 200));
|
||||
return mapper.findPublishedByType(q).stream().map(CmsService::toPublicDto).toList();
|
||||
}
|
||||
|
||||
// ── 매핑 ─────────────────────────────────────────────────────────────────
|
||||
private static CmsContentDto toContentDto(Map<String, Object> r) {
|
||||
return new CmsContentDto(
|
||||
@ -142,12 +321,62 @@ public class CmsService {
|
||||
str(r.get("createdAt")), str(r.get("updatedAt")));
|
||||
}
|
||||
|
||||
/** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */
|
||||
private static CmsContentDto toPublicDto(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")),
|
||||
null, false, false, null, str(r.get("publishedAt")),
|
||||
null, 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 CmsContentVersionDto toVersionDto(Map<String, Object> r) {
|
||||
return new CmsContentVersionDto(
|
||||
str(r.get("id")), str(r.get("contentId")),
|
||||
((Number) r.get("versionNo")).intValue(),
|
||||
str(r.get("title")), str(r.get("body")), str(r.get("status")),
|
||||
str(r.get("authorName")), str(r.get("note")), str(r.get("createdAt")));
|
||||
}
|
||||
|
||||
private static CmsMediaDto toMediaDto(Map<String, Object> r) {
|
||||
return new CmsMediaDto(
|
||||
str(r.get("id")), str(r.get("eventId")), str(r.get("fileName")), str(r.get("url")),
|
||||
str(r.get("contentType")),
|
||||
r.get("sizeBytes") == null ? null : ((Number) r.get("sizeBytes")).longValue(),
|
||||
str(r.get("altText")), str(r.get("uploaderName")), str(r.get("createdAt")));
|
||||
}
|
||||
|
||||
private static String resolveExtension(MultipartFile file) {
|
||||
String byType = file.getContentType() == null
|
||||
? null : ALLOWED_TYPES.get(file.getContentType().toLowerCase(Locale.ROOT));
|
||||
if (byType != null) {
|
||||
return byType;
|
||||
}
|
||||
String name = file.getOriginalFilename();
|
||||
if (name != null && name.contains(".")) {
|
||||
String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
if (ALLOWED_EXTS.contains(ext)) {
|
||||
return "jpeg".equals(ext) ? "jpg" : ext;
|
||||
}
|
||||
}
|
||||
throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP/GIF 이미지만 업로드할 수 있습니다.");
|
||||
}
|
||||
|
||||
/** 표시용 파일명 — 원본은 신뢰하지 않고 경로 구분자/제어문자 제거 후 길이 제한. */
|
||||
private static String safeDisplayName(String original, String ext) {
|
||||
if (original == null || original.isBlank()) {
|
||||
return "image." + ext;
|
||||
}
|
||||
String base = original.replaceAll("[\\\\/\\x00-\\x1f]", "_").trim();
|
||||
return base.length() > 120 ? base.substring(0, 120) : base;
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return s == null || s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
package com.zioinfo.kintex.cms;
|
||||
|
||||
import com.zioinfo.kintex.cms.dto.CmsContentDto;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* M17 공개 콘텐츠 조회 API — 무인증(공개 홍보 사이트·마이크로사이트용). 게시(published) 상태만 노출.
|
||||
* 조회 시 예약 시각 도달분을 자동 게시 처리한다(지연 스윕). 운영 필드(저자·예약·플래그)는 제외.
|
||||
* <ul>
|
||||
* <li>GET /api/public/cms/{type}?eventId=&limit= — type: PAGE|POST|NOTICE|BLOCK</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/public/cms")
|
||||
public class PublicCmsController {
|
||||
|
||||
private final CmsService service;
|
||||
|
||||
public PublicCmsController(CmsService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping("/{type}")
|
||||
public ApiResponse<List<CmsContentDto>> byType(@PathVariable String type,
|
||||
@RequestParam(required = false) String eventId,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
return ApiResponse.ok(service.publicByType(type, eventId, limit));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.zioinfo.kintex.cms.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* CMS 콘텐츠 본문 수정 요청(SCR-35 에디터 저장). 저장 시 이전 상태를 버전 스냅샷으로 보존한다.
|
||||
* body 는 서버에서 sanitize(허용 태그 화이트리스트)된다. scheduledAt: ISO-8601(예약 게시) 또는 null(해제).
|
||||
*/
|
||||
public record CmsContentUpdateRequest(
|
||||
@NotBlank String title,
|
||||
String body,
|
||||
String scheduledAt,
|
||||
Boolean signage,
|
||||
Boolean mailing) {
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.zioinfo.kintex.cms.dto;
|
||||
|
||||
/** CMS 콘텐츠 버전 스냅샷(SCR-35 버전 이력·롤백). note: created|edited|published|rollback-from-vN. */
|
||||
public record CmsContentVersionDto(
|
||||
String id,
|
||||
String contentId,
|
||||
int versionNo,
|
||||
String title,
|
||||
String body,
|
||||
String status,
|
||||
String authorName,
|
||||
String note,
|
||||
String createdAt) {
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.zioinfo.kintex.cms.dto;
|
||||
|
||||
/** CMS 미디어 라이브러리 항목(SCR-35 미디어 스트립). url 은 /uploads/cms/** 서빙 경로. */
|
||||
public record CmsMediaDto(
|
||||
String id,
|
||||
String eventId,
|
||||
String fileName,
|
||||
String url,
|
||||
String contentType,
|
||||
Long sizeBytes,
|
||||
String altText,
|
||||
String uploaderName,
|
||||
String createdAt) {
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package com.zioinfo.kintex.common.text;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* CMS 본문 XSS 방어(N2) — 허용 태그 화이트리스트 기반 서버 sanitize.
|
||||
* <p>외부 의존성(jsoup 등) 없이 순수 정규식 전략으로 위험 요소를 제거한다.
|
||||
* <ul>
|
||||
* <li>{@code <script>}·{@code <style>}·{@code <iframe>}·{@code <object>}·{@code <embed>} 등 위험 태그는 내용째 제거.</li>
|
||||
* <li>{@code on*} 인라인 이벤트 핸들러 속성 제거(onclick·onerror·onload …).</li>
|
||||
* <li>{@code javascript:}·{@code data:}·{@code vbscript:} 스킴 href/src 제거.</li>
|
||||
* <li>화이트리스트에 없는 태그는 태그만 제거하고 <b>텍스트 내용은 보존</b>(escape 아님).</li>
|
||||
* </ul>
|
||||
* 에디터는 블록/리치텍스트(제한 서식)이므로 본문은 서식 태그 소집합만 허용한다.
|
||||
*/
|
||||
public final class HtmlSanitizer {
|
||||
|
||||
/** 허용 태그(여닫기 공통) — 리치텍스트 최소 서식. */
|
||||
private static final Set<String> ALLOWED = Set.of(
|
||||
"p", "br", "b", "strong", "i", "em", "u", "s",
|
||||
"ul", "ol", "li", "blockquote", "code", "pre",
|
||||
"h1", "h2", "h3", "h4", "a", "span", "div");
|
||||
|
||||
/** 내용째 삭제할 위험 태그(여는 태그 ~ 닫는 태그 전체). */
|
||||
private static final Pattern DANGEROUS_BLOCKS = Pattern.compile(
|
||||
"(?is)<\\s*(script|style|iframe|object|embed|form|svg|math|template|noscript)\\b.*?<\\s*/\\s*\\1\\s*>");
|
||||
/** 짝이 맞지 않는 위험 여는/닫는 태그 잔재. */
|
||||
private static final Pattern DANGEROUS_SOLO = Pattern.compile(
|
||||
"(?is)<\\s*/?\\s*(script|style|iframe|object|embed|form|svg|math|template|noscript)\\b[^>]*>");
|
||||
/** on* 인라인 이벤트 핸들러 속성. */
|
||||
private static final Pattern EVENT_ATTR = Pattern.compile(
|
||||
"(?is)\\s+on[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
|
||||
/** 위험 스킴 URL 속성(href/src/xlink:href). */
|
||||
private static final Pattern DANGEROUS_URL = Pattern.compile(
|
||||
"(?is)\\s+(href|src|xlink:href)\\s*=\\s*(\"\\s*(javascript|data|vbscript):[^\"]*\"|'\\s*(javascript|data|vbscript):[^']*'|(javascript|data|vbscript):[^\\s>]+)");
|
||||
/** 일반 태그 매칭(여는/닫는). */
|
||||
private static final Pattern ANY_TAG = Pattern.compile("(?is)<\\s*(/?)\\s*([a-z][a-z0-9]*)\\b[^>]*>");
|
||||
|
||||
private HtmlSanitizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문을 안전하게 정화한다. null/blank 는 그대로 반환.
|
||||
* 최대 길이(200,000자)를 초과하면 잘라낸다(폭주 방어).
|
||||
*/
|
||||
public static String sanitize(String raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return raw;
|
||||
}
|
||||
String s = raw.length() > 200_000 ? raw.substring(0, 200_000) : raw;
|
||||
|
||||
// 1) 위험 블록/잔재 태그 제거(내용째).
|
||||
s = DANGEROUS_BLOCKS.matcher(s).replaceAll("");
|
||||
s = DANGEROUS_SOLO.matcher(s).replaceAll("");
|
||||
// 2) 이벤트 핸들러·위험 스킴 속성 제거.
|
||||
s = EVENT_ATTR.matcher(s).replaceAll("");
|
||||
s = DANGEROUS_URL.matcher(s).replaceAll("");
|
||||
// 3) 화이트리스트에 없는 태그는 태그만 제거(텍스트 보존).
|
||||
Matcher m = ANY_TAG.matcher(s);
|
||||
StringBuilder out = new StringBuilder(s.length());
|
||||
while (m.find()) {
|
||||
String tag = m.group(2).toLowerCase();
|
||||
m.appendReplacement(out, ALLOWED.contains(tag) ? Matcher.quoteReplacement(m.group()) : "");
|
||||
}
|
||||
m.appendTail(out);
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,21 @@
|
||||
package com.zioinfo.kintex.visitor;
|
||||
|
||||
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.PageResponse;
|
||||
import com.zioinfo.kintex.common.audit.Audited;
|
||||
import com.zioinfo.kintex.visitor.dto.VisitorDtos.*;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* M10 관람객·리드 API (SCR-30·SCR-32) + 공개 사전등록 접수.
|
||||
* <ul>
|
||||
@ -63,4 +70,74 @@ public class VisitorController {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.leads(eventId, boothId, page, size));
|
||||
}
|
||||
|
||||
// ── SCR-31 현장 체크인 ──
|
||||
|
||||
/** QR 토큰/등록번호 체크인(중복 방지). 홀매니저·주최자 현장 운영 역할. */
|
||||
@Audited(action = "VISITOR_CHECKIN", targetType = "visitor_registration")
|
||||
@PostMapping("/api/events/{eventId}/checkin")
|
||||
public ApiResponse<CheckinResult> checkin(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@RequestBody CheckinRequest req) {
|
||||
guard.requireRole(principal, eventId, EventRole.HALL_MANAGER, EventRole.ORGANIZER);
|
||||
return ApiResponse.ok(service.checkin(eventId, req));
|
||||
}
|
||||
|
||||
/** 현장 수동 검색(마스킹 결과). */
|
||||
@GetMapping("/api/events/{eventId}/checkin/search")
|
||||
public ApiResponse<List<CheckinSearchItem>> checkinSearch(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@RequestParam("q") String q) {
|
||||
guard.requireRole(principal, eventId, EventRole.HALL_MANAGER, EventRole.ORGANIZER);
|
||||
return ApiResponse.ok(service.searchForCheckin(eventId, q));
|
||||
}
|
||||
|
||||
/** 실시간 입장 집계(시간대별·장내 추정). */
|
||||
@GetMapping("/api/events/{eventId}/checkin/stats")
|
||||
public ApiResponse<CheckinStats> checkinStats(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.checkinStats(eventId));
|
||||
}
|
||||
|
||||
/** 배지 재발급. */
|
||||
@Audited(action = "VISITOR_BADGE_REISSUE", targetType = "visitor_registration")
|
||||
@PostMapping("/api/events/{eventId}/visitors/{registrationId}/badge/reissue")
|
||||
public ApiResponse<BadgeReissueResult> reissueBadge(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String registrationId) {
|
||||
guard.requireRole(principal, eventId, EventRole.HALL_MANAGER, EventRole.ORGANIZER);
|
||||
return ApiResponse.ok(service.reissueBadge(eventId, registrationId));
|
||||
}
|
||||
|
||||
// ── SCR-32 리드 수집·내보내기 ──
|
||||
|
||||
/** 리드 수집(참가업체 부스 스캔) — 서버 규칙 엔진이 스코어 산출. */
|
||||
@Audited(action = "LEAD_COLLECT", targetType = "lead")
|
||||
@PostMapping("/api/events/{eventId}/leads")
|
||||
public ApiResponse<LeadCollectResult> collectLead(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@RequestBody LeadCollectRequest req) {
|
||||
guard.requireRole(principal, eventId, EventRole.EXHIBITOR, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
||||
return ApiResponse.ok(service.collectLead(eventId, req));
|
||||
}
|
||||
|
||||
/** 리드 CSV 내보내기(마스킹 필드만). UTF-8 BOM 포함(엑셀 한글 호환). */
|
||||
@Audited(action = "LEAD_EXPORT_CSV", targetType = "lead")
|
||||
@GetMapping("/api/events/{eventId}/leads/export")
|
||||
public ResponseEntity<byte[]> exportLeadsCsv(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@RequestParam(required = false) String boothId) {
|
||||
guard.requireRole(principal, eventId, EventRole.EXHIBITOR, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
||||
String csv = service.leadsCsv(eventId, boothId);
|
||||
byte[] bom = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
|
||||
byte[] body = csv.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] out = new byte[bom.length + body.length];
|
||||
System.arraycopy(bom, 0, out, 0, bom.length);
|
||||
System.arraycopy(body, 0, out, bom.length, body.length);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"leads-" + eventId + ".csv\"")
|
||||
.body(out);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ 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 org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -161,4 +162,138 @@ public interface VisitorMapper {
|
||||
@Param("agreePrivacy") boolean agreePrivacy,
|
||||
@Param("agreeMarketing") boolean agreeMarketing,
|
||||
@Param("badgeCode") String badgeCode);
|
||||
|
||||
// ── 현장 체크인(SCR-31) ──
|
||||
|
||||
/** QR 토큰 또는 등록ID 로 등록 1건 해소 — 체크인 판정에 필요한 상태·마스킹 필드만 반환(원문 미반환). */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT id AS "registrationId",
|
||||
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 AS "company",
|
||||
badge_code AS "badgeCode",
|
||||
badge_issued AS "badgeIssued",
|
||||
checkin_state AS "checkinState",
|
||||
to_char(checkin_at, 'YYYY.MM.DD HH24:MI') AS "checkinAt"
|
||||
FROM visitor_registration
|
||||
WHERE event_id = #{eventId}
|
||||
<choose>
|
||||
<when test="token != null and token != ''">AND qr_token = #{token}</when>
|
||||
<otherwise>AND id = #{registrationId}</otherwise>
|
||||
</choose>
|
||||
LIMIT 1
|
||||
</script>
|
||||
""")
|
||||
Map<String, Object> findForCheckin(@Param("eventId") String eventId,
|
||||
@Param("token") String token,
|
||||
@Param("registrationId") String registrationId);
|
||||
|
||||
/** 체크인 확정 — 아직 완료가 아닌 건만 done 처리(중복 방지: 이미 done 이면 0 행 갱신). 갱신 행 수 반환. */
|
||||
@Update("""
|
||||
UPDATE visitor_registration
|
||||
SET checkin_state = 'done', checkin_at = now(), badge_issued = true
|
||||
WHERE event_id = #{eventId} AND id = #{registrationId}
|
||||
AND checkin_state <> 'done'
|
||||
""")
|
||||
int markCheckedIn(@Param("eventId") String eventId, @Param("registrationId") String registrationId);
|
||||
|
||||
/** 현장 수동 검색 — 이름/연락처(숫자)/배지코드/등록ID 부분일치. 마스킹 필드만 반환. */
|
||||
@Select("""
|
||||
SELECT id AS "registrationId",
|
||||
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 AS "company",
|
||||
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",
|
||||
badge_code AS "badgeCode",
|
||||
checkin_state AS "checkinState"
|
||||
FROM visitor_registration
|
||||
WHERE event_id = #{eventId}
|
||||
AND ( name ILIKE '%' || #{q} || '%'
|
||||
OR regexp_replace(coalesce(phone,''),'\\D','','g') LIKE '%' || regexp_replace(#{q},'\\D','','g') || '%'
|
||||
OR upper(badge_code) LIKE '%' || upper(#{q}) || '%'
|
||||
OR id = #{q} )
|
||||
AND ( #{qDigits} = '' OR regexp_replace(coalesce(phone,''),'\\D','','g') LIKE '%' || #{qDigits} || '%'
|
||||
OR name ILIKE '%' || #{q} || '%' OR upper(badge_code) LIKE '%' || upper(#{q}) || '%' OR id = #{q} )
|
||||
ORDER BY registered_at DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
List<Map<String, Object>> searchRegistrations(@Param("eventId") String eventId,
|
||||
@Param("q") String q,
|
||||
@Param("qDigits") String qDigits);
|
||||
|
||||
// ── 배지 재발급 ──
|
||||
|
||||
/** 배지 재발급 — 새 배지코드+QR 토큰 갱신, 발급 표시. 갱신 행 수 반환. */
|
||||
@Update("""
|
||||
UPDATE visitor_registration
|
||||
SET badge_code = #{badgeCode}, qr_token = #{qrToken}, badge_issued = true
|
||||
WHERE event_id = #{eventId} AND id = #{registrationId}
|
||||
""")
|
||||
int reissueBadge(@Param("eventId") String eventId, @Param("registrationId") String registrationId,
|
||||
@Param("badgeCode") String badgeCode, @Param("qrToken") String qrToken);
|
||||
|
||||
// ── 실시간 입장 집계(SCR-31) ──
|
||||
|
||||
/** 시간대별 체크인 집계(당일 checkin_at 기준). */
|
||||
@Select("""
|
||||
SELECT to_char(checkin_at, 'HH24') || '시' AS "hour",
|
||||
count(*) AS "count"
|
||||
FROM visitor_registration
|
||||
WHERE event_id = #{eventId} AND checkin_state = 'done' AND checkin_at IS NOT NULL
|
||||
GROUP BY to_char(checkin_at, 'HH24')
|
||||
ORDER BY to_char(checkin_at, 'HH24')
|
||||
""")
|
||||
List<Map<String, Object>> findCheckinByHour(@Param("eventId") String eventId);
|
||||
|
||||
// ── 리드 수집(SCR-32) ──
|
||||
|
||||
/** 리드 삽입 — 원문 저장(응답 노출 금지). score·ai_reasons 는 서비스가 규칙 엔진으로 산출해 전달. */
|
||||
@Insert("""
|
||||
INSERT INTO lead
|
||||
(id, event_id, booth_id, exhibitor_id, name, phone, email, role, company, product,
|
||||
interest, score, ai_reasons, activity, note, followup_draft, ai_generated, agree_privacy, collected_at)
|
||||
VALUES
|
||||
(#{id}, #{eventId}, #{boothId}, #{exhibitorId}, #{name}, #{phone}, #{email}, #{role}, #{company}, #{product},
|
||||
#{interest}, #{score}, CAST(#{aiReasonsJson} AS jsonb), '[]'::jsonb, #{note}, NULL, false, #{agreePrivacy}, now())
|
||||
""")
|
||||
int insertLead(@Param("id") String id, @Param("eventId") String eventId, @Param("boothId") String boothId,
|
||||
@Param("exhibitorId") String exhibitorId, @Param("name") String name, @Param("phone") String phone,
|
||||
@Param("email") String email, @Param("role") String role, @Param("company") String company,
|
||||
@Param("product") String product, @Param("interest") int interest, @Param("score") int score,
|
||||
@Param("aiReasonsJson") String aiReasonsJson, @Param("note") String note,
|
||||
@Param("agreePrivacy") boolean agreePrivacy);
|
||||
|
||||
/** CSV 내보내기용 리드 조회 — 마스킹 필드만(원문 미반환). booth 선택 필터. */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT 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,
|
||||
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",
|
||||
to_char(collected_at, 'YYYY-MM-DD HH24:MI:SS') AS "collectedAt"
|
||||
FROM lead
|
||||
WHERE event_id = #{eventId}
|
||||
<if test="boothId != null and boothId != ''">AND booth_id = #{boothId}</if>
|
||||
ORDER BY score DESC, collected_at DESC
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> findLeadsForCsv(@Param("eventId") String eventId, @Param("boothId") String boothId);
|
||||
}
|
||||
|
||||
@ -5,6 +5,9 @@ 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 com.zioinfo.kintex.visitor.scoring.LeadScorer;
|
||||
import com.zioinfo.kintex.visitor.scoring.LeadSignals;
|
||||
import com.zioinfo.kintex.visitor.scoring.ScoreResult;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -29,9 +32,11 @@ public class VisitorService {
|
||||
"vip", new String[]{"VIP", "#0E8A5F"});
|
||||
|
||||
private final VisitorMapper mapper;
|
||||
private final LeadScorer leadScorer;
|
||||
|
||||
public VisitorService(VisitorMapper mapper) {
|
||||
public VisitorService(VisitorMapper mapper, LeadScorer leadScorer) {
|
||||
this.mapper = mapper;
|
||||
this.leadScorer = leadScorer;
|
||||
}
|
||||
|
||||
// ── SCR-30 요약 ──
|
||||
@ -177,6 +182,145 @@ public class VisitorService {
|
||||
return new RegisterResult(id, badgeCode);
|
||||
}
|
||||
|
||||
// ── SCR-31 현장 체크인 ──
|
||||
|
||||
/**
|
||||
* QR 토큰(우선) 또는 등록ID 로 체크인. 이미 완료된 건은 재집계 없이 {@code alreadyCheckedIn=true} 로 반환한다(중복 방지).
|
||||
* 체크인 판정은 매퍼의 조건부 UPDATE(상태 != 'done' 인 행만 갱신)로 원자적으로 수행된다.
|
||||
*/
|
||||
public CheckinResult checkin(String eventId, CheckinRequest req) {
|
||||
String token = trimOrNull(req == null ? null : req.token());
|
||||
String regId = trimOrNull(req == null ? null : req.registrationId());
|
||||
if (token == null && regId == null) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "QR 토큰 또는 등록번호가 필요합니다.");
|
||||
}
|
||||
Map<String, Object> r = mapper.findForCheckin(eventId, token, regId);
|
||||
if (r == null || r.get("registrationId") == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "등록 정보를 찾을 수 없습니다. 현장 등록을 진행해 주세요.");
|
||||
}
|
||||
String rid = str(r.get("registrationId"));
|
||||
boolean wasDone = "done".equals(str(r.get("checkinState")));
|
||||
int updated = wasDone ? 0 : mapper.markCheckedIn(eventId, rid);
|
||||
boolean alreadyCheckedIn = wasDone || updated == 0; // 경합 시에도 0 → 이미 완료로 간주
|
||||
// 갱신 후 체크인 시각 재조회(방금 체크인이면 now 반영).
|
||||
Map<String, Object> after = mapper.findForCheckin(eventId, null, rid);
|
||||
Map<String, Object> src = after != null ? after : r;
|
||||
return new CheckinResult(rid, str(src.get("nameMasked")), str(src.get("type")), str(src.get("company")),
|
||||
str(src.get("badgeCode")), bool(src.get("badgeIssued")), str(src.get("checkinState")),
|
||||
alreadyCheckedIn, str(src.get("checkinAt")));
|
||||
}
|
||||
|
||||
/** 현장 수동 검색(이름/연락처/배지코드/등록번호). 마스킹 결과만 반환. */
|
||||
public List<CheckinSearchItem> searchForCheckin(String eventId, String q) {
|
||||
String query = trimOrNull(q);
|
||||
if (query == null || query.length() < 2) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "검색어는 2자 이상 입력해 주세요.");
|
||||
}
|
||||
String digits = query.replaceAll("\\D", "");
|
||||
List<Map<String, Object>> rows = mapper.searchRegistrations(eventId, query, digits);
|
||||
List<CheckinSearchItem> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new CheckinSearchItem(str(r.get("registrationId")), str(r.get("nameMasked")),
|
||||
str(r.get("type")), str(r.get("company")), str(r.get("phoneMasked")),
|
||||
str(r.get("badgeCode")), str(r.get("checkinState"))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 실시간 입장 집계 — 사전등록/체크인/장내 추정 + 시간대별 버킷. */
|
||||
public CheckinStats checkinStats(String eventId) {
|
||||
Map<String, Object> c = orEmpty(mapper.findSummaryCounts(eventId));
|
||||
long total = lng(c.get("total"));
|
||||
long checkedIn = lng(c.get("checkedIn"));
|
||||
List<Map<String, Object>> hours = mapper.findCheckinByHour(eventId);
|
||||
List<HourBucket> byHour = new ArrayList<>();
|
||||
if (hours != null) {
|
||||
for (Map<String, Object> h : hours) {
|
||||
byHour.add(new HourBucket(str(h.get("hour")), lng(h.get("count"))));
|
||||
}
|
||||
}
|
||||
// 장내 추정: 체크인 인원의 92%(단순 추정 — 퇴장 미추적 환경 근사).
|
||||
long inside = Math.round(checkedIn * 0.92);
|
||||
return new CheckinStats(total, checkedIn, inside, byHour);
|
||||
}
|
||||
|
||||
/** 배지 재발급 — 새 배지코드+QR 토큰 부여(민감정보 아님). */
|
||||
public BadgeReissueResult reissueBadge(String eventId, String registrationId) {
|
||||
String badgeCode = "KTX-V-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
|
||||
String qrToken = "QR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
|
||||
int n = mapper.reissueBadge(eventId, registrationId, badgeCode, qrToken);
|
||||
if (n == 0) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "등록 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
return new BadgeReissueResult(registrationId, badgeCode, true);
|
||||
}
|
||||
|
||||
// ── SCR-32 리드 수집(규칙 엔진 스코어) ──
|
||||
|
||||
/** 참가업체 부스 리드 수집. 스코어는 서버 규칙 엔진이 산출(클라이언트 값 신뢰 안 함). AI 보정 지점은 인터페이스로 열어둠. */
|
||||
public LeadCollectResult collectLead(String eventId, LeadCollectRequest req) {
|
||||
String name = trimOrNull(req.name());
|
||||
if (name == null) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "리드 이름을 입력해 주세요.");
|
||||
}
|
||||
if (req.agreePrivacy() != null && !req.agreePrivacy()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집 동의가 없는 리드는 저장할 수 없습니다.");
|
||||
}
|
||||
int interest = clamp(req.interest() == null ? 3 : req.interest(), 1, 5);
|
||||
LeadSignals signals = new LeadSignals(
|
||||
interest,
|
||||
req.dwellRatioPct() == null ? 100 : Math.max(req.dwellRatioPct(), 0),
|
||||
req.revisitCount() == null ? 0 : Math.max(req.revisitCount(), 0),
|
||||
Boolean.TRUE.equals(req.decisionMaker()),
|
||||
req.docDownloads() == null ? 0 : Math.max(req.docDownloads(), 0));
|
||||
ScoreResult sr = leadScorer.score(signals);
|
||||
|
||||
String id = "ld-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
String reasonsJson;
|
||||
try {
|
||||
reasonsJson = JSON.writeValueAsString(sr.reasons());
|
||||
} catch (Exception e) {
|
||||
reasonsJson = "[]";
|
||||
}
|
||||
mapper.insertLead(id, eventId, trimOrNull(req.boothId()), trimOrNull(req.exhibitorId()),
|
||||
name, trimOrNull(req.phone()), trimOrNull(req.email()), trimOrNull(req.role()),
|
||||
trimOrNull(req.company()), trimOrNull(req.product()), interest, sr.score(), reasonsJson,
|
||||
trimOrNull(req.note()), req.agreePrivacy() == null || req.agreePrivacy());
|
||||
return new LeadCollectResult(id, sr.score(), sr.reasons(), sr.source());
|
||||
}
|
||||
|
||||
/** 리드 CSV(마스킹 필드만). RFC4180 유사 이스케이프. UTF-8 BOM 은 컨트롤러가 부여. */
|
||||
public String leadsCsv(String eventId, String boothId) {
|
||||
String bid = (boothId == null || boothId.isBlank()) ? null : boothId;
|
||||
List<Map<String, Object>> rows = mapper.findLeadsForCsv(eventId, bid);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("이름(마스킹),소속,직급,관심제품,관심도,AI스코어,연락처(마스킹),이메일(마스킹),수집시각\r\n");
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
sb.append(csv(str(r.get("nameMasked")))).append(',')
|
||||
.append(csv(str(r.get("company")))).append(',')
|
||||
.append(csv(str(r.get("role")))).append(',')
|
||||
.append(csv(str(r.get("product")))).append(',')
|
||||
.append(intVal(r.get("interest"))).append(',')
|
||||
.append(intVal(r.get("score"))).append(',')
|
||||
.append(csv(str(r.get("phoneMasked")))).append(',')
|
||||
.append(csv(str(r.get("emailMasked")))).append(',')
|
||||
.append(csv(str(r.get("collectedAt")))).append("\r\n");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String csv(String v) {
|
||||
if (v == null) return "";
|
||||
if (v.contains(",") || v.contains("\"") || v.contains("\n") || v.contains("\r")) {
|
||||
return '"' + v.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
private static String normalizeType(String t) {
|
||||
if (t == null) return "visitor";
|
||||
|
||||
@ -49,4 +49,42 @@ public final class VisitorDtos {
|
||||
|
||||
public record RegisterResult(String registrationId, String badgeCode) {
|
||||
}
|
||||
|
||||
// ── SCR-31 현장 체크인 ──
|
||||
/** 체크인 요청 — QR 토큰 우선, 없으면 등록ID(수동 검색 후 체크인). */
|
||||
public record CheckinRequest(String token, String registrationId) {
|
||||
}
|
||||
|
||||
/** 체크인 결과 — 원문 PII 미포함(마스킹 이름만). {@code alreadyCheckedIn} 이면 중복 스캔(재집계 없음). */
|
||||
public record CheckinResult(String registrationId, String nameMasked, String type, String company,
|
||||
String badgeCode, boolean badgeIssued, String checkinState,
|
||||
boolean alreadyCheckedIn, String checkinAt) {
|
||||
}
|
||||
|
||||
/** 현장 수동 검색 결과(마스킹) — 데스크가 선택 후 체크인. */
|
||||
public record CheckinSearchItem(String registrationId, String nameMasked, String type, String company,
|
||||
String phoneMasked, String badgeCode, String checkinState) {
|
||||
}
|
||||
|
||||
/** 실시간 입장 집계(SCR-31 우측 위젯). */
|
||||
public record CheckinStats(long preRegistered, long checkedIn, long inside, List<HourBucket> byHour) {
|
||||
}
|
||||
|
||||
public record HourBucket(String hour, long count) {
|
||||
}
|
||||
|
||||
/** 배지 재발급 결과 — 새 배지코드(QR 토큰은 서버 내부 갱신, 응답 미노출). */
|
||||
public record BadgeReissueResult(String registrationId, String badgeCode, boolean badgeIssued) {
|
||||
}
|
||||
|
||||
// ── SCR-32 리드 수집 ──
|
||||
/** 리드 수집 요청 — 참가업체 부스에서 배지 스캔·상담. score 는 서버 규칙 엔진이 산출(클라이언트 값 무시). */
|
||||
public record LeadCollectRequest(String name, String phone, String email, String role, String company,
|
||||
String product, String boothId, String exhibitorId, String note,
|
||||
Integer interest, Integer dwellRatioPct, Integer revisitCount,
|
||||
Boolean decisionMaker, Integer docDownloads, Boolean agreePrivacy) {
|
||||
}
|
||||
|
||||
public record LeadCollectResult(String leadId, int score, List<String> aiReasons, String scoreSource) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
package com.zioinfo.kintex.visitor.scoring;
|
||||
|
||||
/**
|
||||
* 리드 스코어링 전략(M10). 기본 구현은 규칙 엔진({@link RuleBasedLeadScorer}).
|
||||
* <p>AI(Claude) 연동 지점: 별도 구현체가 규칙 스코어를 기저로 보정하도록 이 인터페이스만 두고,
|
||||
* 실제 LLM 호출 구현은 후속 하네스(guardia-claude-ai)에서 주입한다. 현재는 규칙 엔진이 단일 빈이다.
|
||||
*/
|
||||
public interface LeadScorer {
|
||||
|
||||
ScoreResult score(LeadSignals signals);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.zioinfo.kintex.visitor.scoring;
|
||||
|
||||
/**
|
||||
* 리드 스코어링 입력 신호(M10 SCR-32).
|
||||
* <p>부스에서 배지 QR 을 스캔·상담할 때 수집되는 결정 신호. 원문 PII 는 포함하지 않는다(스코어 산식과 무관).
|
||||
*
|
||||
* @param interest 관심도(1~5, 참가업체 상담원 입력)
|
||||
* @param dwellRatioPct 부스 체류 시간(행사 평균=100 기준 상대 비율, 예: 240 = 평균 대비 240%)
|
||||
* @param revisitCount 부스 재방문 횟수(0 이상)
|
||||
* @param decisionMaker 구매 결정권자 여부(직급·역할 기반)
|
||||
* @param docDownloads 기술 사양서/카탈로그 등 자료 열람·다운로드 횟수(0 이상)
|
||||
*/
|
||||
public record LeadSignals(
|
||||
int interest,
|
||||
int dwellRatioPct,
|
||||
int revisitCount,
|
||||
boolean decisionMaker,
|
||||
int docDownloads
|
||||
) {
|
||||
|
||||
/** 관심도만 아는 최소 신호(수동 입력 리드). 나머지는 중립값(평균 체류·미재방문). */
|
||||
public static LeadSignals ofInterest(int interest) {
|
||||
return new LeadSignals(interest, 100, 0, false, 0);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.zioinfo.kintex.visitor.scoring;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 규칙 기반 리드 스코어 엔진(M10 기본). 결정론적·검증 가능한 가중 합산.
|
||||
* <p><b>산식(총점 100 = 관심도 40 + 체류 25 + 재방문 20 + 결정권자 10 + 자료열람 5):</b>
|
||||
* <ul>
|
||||
* <li>관심도(interest 1~5) × 8 → 8..40</li>
|
||||
* <li>체류: (dwellRatioPct − 100) × 0.25 를 [0,25] 로 클램프(평균 100 → 0, 200% → 25)</li>
|
||||
* <li>재방문: revisitCount × 8 을 [0,20] 로 클램프</li>
|
||||
* <li>결정권자: 10 (해당 시)</li>
|
||||
* <li>자료열람: docDownloads × 5 를 [0,5] 로 클램프</li>
|
||||
* </ul>
|
||||
* 스코어는 [0,100] 로 최종 클램프한다. AI 연동은 별도 어드바이저가 이 결과를 보정한다.
|
||||
*/
|
||||
@Component
|
||||
public class RuleBasedLeadScorer implements LeadScorer {
|
||||
|
||||
static final int W_INTEREST = 8; // per interest point (max 40 at interest=5)
|
||||
static final int MAX_DWELL = 25;
|
||||
static final int MAX_REVISIT = 20;
|
||||
static final int W_REVISIT = 8;
|
||||
static final int P_DECISION_MAKER = 10;
|
||||
static final int MAX_DOC = 5;
|
||||
static final int W_DOC = 5;
|
||||
|
||||
@Override
|
||||
public ScoreResult score(LeadSignals s) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
int interest = clamp(s.interest(), 1, 5);
|
||||
int interestPts = interest * W_INTEREST;
|
||||
if (interest >= 4) {
|
||||
reasons.add("상담원 관심도 " + interest + "점(상)으로 구매 의향 높음");
|
||||
} else if (interest >= 3) {
|
||||
reasons.add("상담원 관심도 " + interest + "점(중)");
|
||||
}
|
||||
|
||||
int dwellPts = clamp((int) Math.round((s.dwellRatioPct() - 100) * 0.25), 0, MAX_DWELL);
|
||||
if (s.dwellRatioPct() >= 150) {
|
||||
reasons.add("부스 체류 시간 평균 대비 " + s.dwellRatioPct() + "%");
|
||||
}
|
||||
|
||||
int revisitPts = clamp(s.revisitCount() * W_REVISIT, 0, MAX_REVISIT);
|
||||
if (s.revisitCount() >= 1) {
|
||||
reasons.add("부스 재방문 " + s.revisitCount() + "회");
|
||||
}
|
||||
|
||||
int dmPts = s.decisionMaker() ? P_DECISION_MAKER : 0;
|
||||
if (s.decisionMaker()) {
|
||||
reasons.add("구매 결정권자 직급으로 확인");
|
||||
}
|
||||
|
||||
int docPts = clamp(s.docDownloads() * W_DOC, 0, MAX_DOC);
|
||||
if (s.docDownloads() >= 1) {
|
||||
reasons.add("기술 자료 열람/다운로드 " + s.docDownloads() + "건");
|
||||
}
|
||||
|
||||
int score = clamp(interestPts + dwellPts + revisitPts + dmPts + docPts, 0, 100);
|
||||
if (reasons.isEmpty()) {
|
||||
reasons.add("기본 관심도 기반 스코어");
|
||||
}
|
||||
return new ScoreResult(score, reasons, "rule");
|
||||
}
|
||||
|
||||
private static int clamp(int v, int min, int max) {
|
||||
return Math.max(min, Math.min(v, max));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.zioinfo.kintex.visitor.scoring;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 리드 스코어 산출 결과.
|
||||
*
|
||||
* @param score 0~100 종합 스코어
|
||||
* @param reasons 근거 문구(가중치가 실린 신호만, 표시·감사용)
|
||||
* @param source 산출 출처("rule" = 규칙 엔진, "ai" = Claude 보정)
|
||||
*/
|
||||
public record ScoreResult(int score, List<String> reasons, String source) {
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
-- V21: 2차 웨이브 확장 — 체크인·리드 확장(M10) + CMS 버전 이력·미디어(M17)
|
||||
-- V17/V19 는 이미 dev 서버에 적용(Flyway 체크섬 고정) → 확장분을 본 마이그레이션으로 분리. 전부 멱등.
|
||||
|
||||
|
||||
-- ── 체크인·배지 QR 확장(멱등 ALTER) ─────────────────────────────────────────
|
||||
-- QR 토큰: 배지 코드와 분리된 불투명 검증 토큰(현장 체크인 스캔 대상). checkin_at: 체크인 시각(중복 방지·집계 근거).
|
||||
ALTER TABLE visitor_registration ADD COLUMN IF NOT EXISTS qr_token varchar(64);
|
||||
ALTER TABLE visitor_registration ADD COLUMN IF NOT EXISTS checkin_at timestamptz;
|
||||
-- 기존/시드 행 백필(결정론적 · 데모): 토큰은 id 해시, 완료건은 등록 2시간 후로 가정.
|
||||
UPDATE visitor_registration SET qr_token = 'QR-' || upper(substr(md5(id), 1, 20)) WHERE qr_token IS NULL;
|
||||
UPDATE visitor_registration SET checkin_at = registered_at + interval '2 hours'
|
||||
WHERE checkin_state = 'done' AND checkin_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_visitor_reg_qr ON visitor_registration (qr_token) WHERE qr_token IS NOT NULL;
|
||||
|
||||
-- ── 리드 소유·동의·메모 확장(멱등 ALTER) ────────────────────────────────────
|
||||
-- exhibitor_id: 소유 참가업체(수집 주체) · agree_privacy: 개인정보 수집 동의 · note: 상담 메모.
|
||||
ALTER TABLE lead ADD COLUMN IF NOT EXISTS exhibitor_id varchar(40);
|
||||
ALTER TABLE lead ADD COLUMN IF NOT EXISTS agree_privacy boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE lead ADD COLUMN IF NOT EXISTS note text;
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_exhibitor ON lead (event_id, exhibitor_id);
|
||||
|
||||
|
||||
-- ── 콘텐츠 버전 이력(SCR-35 우측 버전 이력·롤백) ────────────────────────────────
|
||||
-- 콘텐츠 본문/제목 저장·상태 게시·롤백 시점마다 스냅샷 1행. version_no 는 콘텐츠별 단조 증가.
|
||||
CREATE TABLE IF NOT EXISTS cms_content_version (
|
||||
id varchar(40) PRIMARY KEY,
|
||||
content_id varchar(40) NOT NULL REFERENCES cms_content(id) ON DELETE CASCADE,
|
||||
version_no int NOT NULL,
|
||||
title varchar(300),
|
||||
body text,
|
||||
status varchar(20),
|
||||
author_name varchar(120),
|
||||
note varchar(200), -- 스냅샷 사유(created|edited|published|rollback...)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (content_id, version_no)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cms_version_content ON cms_content_version(content_id, version_no DESC);
|
||||
|
||||
-- ── 미디어 라이브러리(SCR-35 중앙 미디어 스트립) ──────────────────────────────
|
||||
-- 기존 업로드 패턴(login-slides) 재사용 — 파일은 {kintex.upload.dir}/cms/ 아래 UUID 저장, /uploads/cms/** 서빙.
|
||||
CREATE TABLE IF NOT EXISTS cms_media (
|
||||
id varchar(40) PRIMARY KEY,
|
||||
event_id varchar(40),
|
||||
file_name varchar(300) NOT NULL, -- 표시용 원본 파일명(신뢰 안 함·표시 전용)
|
||||
url varchar(500) NOT NULL, -- /uploads/cms/{uuid.ext}
|
||||
content_type varchar(80),
|
||||
size_bytes bigint,
|
||||
alt_text varchar(300), -- 대체 텍스트(접근성·AI 태깅 예정)
|
||||
uploader_name varchar(120),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cms_media_created ON cms_media(created_at DESC);
|
||||
|
||||
-- 버전 시드(멱등): 게시 워크플로 데모용 — cms-about 3버전, cms-home 1버전.
|
||||
INSERT INTO cms_content_version (id, content_id, version_no, title, body, status, author_name, note)
|
||||
VALUES
|
||||
('cv-about-1', 'cms-about', 1, '행사 소개(초안)', '스마트 팩토리 엑스포 초안 본문.', 'draft', '관리자', 'created'),
|
||||
('cv-about-2', 'cms-about', 2, '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개(보강).', 'review', '관리자', 'edited'),
|
||||
('cv-about-3', 'cms-about', 3, '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개', 'review', '관리자', 'edited'),
|
||||
('cv-home-1', 'cms-home', 1, '페이지', '전시 공식 홈 페이지 본문', 'published','관리자', 'published')
|
||||
ON CONFLICT (content_id, version_no) DO NOTHING;
|
||||
@ -0,0 +1,55 @@
|
||||
package com.zioinfo.kintex.auction;
|
||||
|
||||
import com.zioinfo.kintex.auction.dto.AuctionDtos.Weights;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* M15 낙찰 종합점수 산식 단위테스트 — 가격·평판·납기 정규화 가중합(SCR-29 비교표 스코어).
|
||||
* 최저가·최단납기·최고평판이 각 축 100점이며, comprehensive 는 가중 평균·lowest 는 가격만 사용함을 고정.
|
||||
*/
|
||||
class AuctionScoringTest {
|
||||
|
||||
@Test
|
||||
void lowestCriteria_usesPriceScoreOnly() {
|
||||
Weights w = new Weights(60, 25, 15); // lowest 에선 가중치 무시
|
||||
// 최저가(8.4M)=100점, 8.7M=8.4/8.7*100=96.6
|
||||
assertEquals(100.0, AuctionScoring.score("lowest", w, 8_400_000L, 8_400_000L, 12, 10, 4.8), 1e-9);
|
||||
assertEquals(96.6, AuctionScoring.score("lowest", w, 8_700_000L, 8_400_000L, 10, 10, 4.6), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void comprehensive_weightedAverageOfNormalizedAxes() {
|
||||
Weights w = new Weights(60, 25, 15);
|
||||
// 업체 A: 최저가 8.4M(가격 100), 평판 4.8(=96), 납기 12일(최단 10 → 83.3)
|
||||
// score = (100*60 + 96*25 + 83.3*15)/100 = (6000 + 2400 + 1249.5)/100 = 96.495 → 96.5
|
||||
double a = AuctionScoring.score("comprehensive", w, 8_400_000L, 8_400_000L, 12, 10, 4.8);
|
||||
assertEquals(96.5, a, 1e-9);
|
||||
|
||||
// 업체 B: 8.7M(가격 96.55), 평판 4.6(=92), 납기 10일 최단(100)
|
||||
// score = (96.5517*60 + 92*25 + 100*15)/100 = (5793.10 + 2300 + 1500)/100 = 95.931 → 95.9
|
||||
double b = AuctionScoring.score("comprehensive", w, 8_700_000L, 8_400_000L, 10, 10, 4.6);
|
||||
assertEquals(95.9, b, 1e-9);
|
||||
|
||||
assertTrue(a > b, "최저가+최고평판 업체 A 가 종합 우위여야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroWeights_doNotDivideByZero() {
|
||||
Weights w = new Weights(0, 0, 0);
|
||||
double s = AuctionScoring.score("comprehensive", w, 5_000_000L, 5_000_000L, 8, 8, 4.0);
|
||||
assertTrue(s >= 0.0 && s <= 100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void axisScores_bounded0to100() {
|
||||
assertEquals(100.0, AuctionScoring.priceScore(1000L, 1000L), 1e-9);
|
||||
assertTrue(AuctionScoring.priceScore(2000L, 1000L) < 100.0);
|
||||
assertEquals(100.0, AuctionScoring.reputationScore(5.0), 1e-9);
|
||||
assertEquals(80.0, AuctionScoring.reputationScore(4.0), 1e-9);
|
||||
assertEquals(100.0, AuctionScoring.deliveryScore(10, 10), 1e-9);
|
||||
assertTrue(AuctionScoring.deliveryScore(20, 10) < 100.0);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* M15 견적서 서버 재계산 단위테스트 — 소계=Σ(수량×단가)·부가세 10%·총액·검증.
|
||||
* 클라이언트가 보낸 금액을 신뢰하지 않고 서버가 라인아이템으로 재산출함을 고정한다.
|
||||
*/
|
||||
class QuotationCalcTest {
|
||||
|
||||
@Test
|
||||
void fromLines_sumsQtyTimesUnit_andAppliesVat() {
|
||||
// 골조 48×45,000 + 전기 1×1,240,000 + 그래픽 12×95,000 = 2,160,000 + 1,240,000 + 1,140,000 = 4,540,000
|
||||
List<QuoteLine> lines = List.of(
|
||||
new QuoteLine("골조", "알루미늄 프로파일", 48L, 45_000L),
|
||||
new QuoteLine("전기", "분전반 일체", 1L, 1_240_000L),
|
||||
new QuoteLine("그래픽", "출력·시공", 12L, 95_000L));
|
||||
|
||||
QuotationCalc.Totals t = QuotationCalc.fromLines(lines);
|
||||
|
||||
assertEquals(4_540_000L, t.subtotal()); // 부가세 별도 경쟁 금액
|
||||
assertEquals(454_000L, t.vat()); // 10%
|
||||
assertEquals(4_994_000L, t.grandTotal()); // 소계 + 부가세
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromLines_roundsVatHalfUp() {
|
||||
// 소계 8,399,999 → 부가세 round(839,999.9)=840,000
|
||||
QuotationCalc.Totals t = QuotationCalc.fromLines(
|
||||
List.of(new QuoteLine("일식", "복합공사", 1L, 8_399_999L)));
|
||||
assertEquals(8_399_999L, t.subtotal());
|
||||
assertEquals(840_000L, t.vat());
|
||||
assertEquals(9_239_999L, t.grandTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromTotal_treatsAmountAsExVatSubtotal() {
|
||||
QuotationCalc.Totals t = QuotationCalc.fromTotal(8_400_000L);
|
||||
assertEquals(8_400_000L, t.subtotal());
|
||||
assertEquals(840_000L, t.vat());
|
||||
assertEquals(9_240_000L, t.grandTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNegativeQuantityOrUnitPrice() {
|
||||
ApiException e1 = assertThrows(ApiException.class,
|
||||
() -> QuotationCalc.fromLines(List.of(new QuoteLine("골조", "자재", -1L, 45_000L))));
|
||||
assertEquals(ErrorCode.VALIDATION, e1.getCode());
|
||||
|
||||
assertThrows(ApiException.class,
|
||||
() -> QuotationCalc.fromLines(List.of(new QuoteLine("골조", "자재", 5L, -100L))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyLinesAndZeroSubtotal() {
|
||||
assertThrows(ApiException.class, () -> QuotationCalc.fromLines(List.of()));
|
||||
assertThrows(ApiException.class,
|
||||
() -> QuotationCalc.fromLines(List.of(new QuoteLine("무상", "샘플", 0L, 0L))));
|
||||
assertThrows(ApiException.class, () -> QuotationCalc.fromTotal(0L));
|
||||
assertThrows(ApiException.class, () -> QuotationCalc.fromTotal(null));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
package com.zioinfo.kintex.cms;
|
||||
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.cms.dto.CmsContentUpdateRequest;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* M17 CMS 서비스 단위테스트 — 게시 전이 가드(전진만·권한)·본문 sanitize·버전 스냅샷·롤백·AI 번역 501.
|
||||
*/
|
||||
class CmsServiceTest {
|
||||
|
||||
private CmsMapper mapper;
|
||||
private SystemAccessGuard scope;
|
||||
private CmsService service;
|
||||
|
||||
private final KintexPrincipal editor =
|
||||
new KintexPrincipal("u1", "편집자", Map.of(), false);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(CmsMapper.class);
|
||||
scope = mock(SystemAccessGuard.class);
|
||||
service = new CmsService(mapper, scope, "./build/test-uploads");
|
||||
}
|
||||
|
||||
private Map<String, Object> contentRow(String status) {
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("id", "cms-1");
|
||||
r.put("title", "제목");
|
||||
r.put("body", "본문");
|
||||
r.put("status", status);
|
||||
r.put("contentType", "PAGE");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 상태 전이 가드 ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void transitionRejectsBackwardMove() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("published"));
|
||||
ApiException ex = assertThrows(ApiException.class,
|
||||
() -> service.transition(editor, "cms-1", "review"));
|
||||
assertEquals(ErrorCode.VALIDATION, ex.getCode());
|
||||
verify(mapper, never()).updateStatus(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transitionRejectsUnknownStatus() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
|
||||
ApiException ex = assertThrows(ApiException.class,
|
||||
() -> service.transition(editor, "cms-1", "archived"));
|
||||
assertEquals(ErrorCode.VALIDATION, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishTransitionRequiresManagerAndSnapshots() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("approved"));
|
||||
when(mapper.maxVersionNo("cms-1")).thenReturn(2);
|
||||
service.transition(editor, "cms-1", "published");
|
||||
verify(scope).requireManager(editor); // 게시는 매니저↑ 게이트
|
||||
verify(mapper).updateStatus("cms-1", "published");
|
||||
verify(mapper).insertVersion(any()); // 게시 시 버전 스냅샷
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewRequestNeedsNoManagerGate() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
|
||||
service.transition(editor, "cms-1", "review");
|
||||
verify(scope, never()).requireManager(any());
|
||||
verify(mapper).updateStatus("cms-1", "review");
|
||||
}
|
||||
|
||||
// ── 본문 sanitize + 버전 스냅샷 ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateSanitizesBodyBeforePersistAndSnapshots() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
|
||||
when(mapper.maxVersionNo("cms-1")).thenReturn(0);
|
||||
var req = new CmsContentUpdateRequest(
|
||||
"제목", "<p>안전</p><script>alert('xss')</script>", null, true, false);
|
||||
|
||||
service.update(editor, "cms-1", req);
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> cap = ArgumentCaptor.forClass(Map.class);
|
||||
verify(mapper).updateContent(cap.capture());
|
||||
String storedBody = String.valueOf(cap.getValue().get("body"));
|
||||
assertTrue(storedBody.contains("<p>안전</p>"), "허용 태그는 보존");
|
||||
assertFalse(storedBody.toLowerCase().contains("<script"), "script 태그 제거");
|
||||
assertFalse(storedBody.contains("alert"), "script 내용 제거");
|
||||
verify(mapper).insertVersion(any()); // 저장 시 버전 스냅샷
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRejectsBlankTitle() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
|
||||
var req = new CmsContentUpdateRequest(" ", "본문", null, null, null);
|
||||
ApiException ex = assertThrows(ApiException.class, () -> service.update(editor, "cms-1", req));
|
||||
assertEquals(ErrorCode.VALIDATION, ex.getCode());
|
||||
}
|
||||
|
||||
// ── 롤백 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void rollbackRestoresVersionAndSnapshots() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("published"));
|
||||
Map<String, Object> ver = new HashMap<>();
|
||||
ver.put("title", "예전제목");
|
||||
ver.put("body", "예전본문");
|
||||
ver.put("status", "review");
|
||||
when(mapper.findVersion("cms-1", 2)).thenReturn(ver);
|
||||
when(mapper.maxVersionNo("cms-1")).thenReturn(4);
|
||||
|
||||
service.rollback(editor, "cms-1", 2);
|
||||
|
||||
verify(scope).requireManager(editor);
|
||||
ArgumentCaptor<Map<String, Object>> cap = ArgumentCaptor.forClass(Map.class);
|
||||
verify(mapper).restoreContent(cap.capture());
|
||||
assertEquals("예전제목", cap.getValue().get("title"));
|
||||
assertEquals("review", cap.getValue().get("status"));
|
||||
verify(mapper).insertVersion(any()); // 롤백 스냅샷
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollbackMissingVersionThrowsNotFound() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
|
||||
when(mapper.findVersion("cms-1", 9)).thenReturn(null);
|
||||
ApiException ex = assertThrows(ApiException.class, () -> service.rollback(editor, "cms-1", 9));
|
||||
assertEquals(ErrorCode.NOT_FOUND, ex.getCode());
|
||||
}
|
||||
|
||||
// ── AI 번역 스텁 501 ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void aiTranslateNotImplementedReturns501Code() {
|
||||
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
|
||||
ApiException ex = assertThrows(ApiException.class, () -> service.aiTranslate("cms-1", "en"));
|
||||
assertEquals(ErrorCode.NOT_IMPLEMENTED, ex.getCode());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.zioinfo.kintex.common.text;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CMS 본문 XSS sanitize(N2) 단위테스트 — 위험 요소 제거·허용 서식 보존.
|
||||
*/
|
||||
class HtmlSanitizerTest {
|
||||
|
||||
@Test
|
||||
void removesScriptTagAndContent() {
|
||||
String out = HtmlSanitizer.sanitize("<p>안녕</p><script>alert('xss')</script>");
|
||||
assertTrue(out.contains("<p>안녕</p>"));
|
||||
assertFalse(out.toLowerCase().contains("<script"));
|
||||
assertFalse(out.contains("alert"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripsInlineEventHandlers() {
|
||||
String out = HtmlSanitizer.sanitize("<a href=\"/ok\" onclick=\"steal()\">링크</a>");
|
||||
assertFalse(out.toLowerCase().contains("onclick"));
|
||||
assertFalse(out.contains("steal()"));
|
||||
assertTrue(out.contains("링크"));
|
||||
assertTrue(out.contains("href=\"/ok\""), "안전한 href 는 보존");
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesJavascriptSchemeUrls() {
|
||||
String out = HtmlSanitizer.sanitize("<a href=\"javascript:alert(1)\">x</a>");
|
||||
assertFalse(out.toLowerCase().contains("javascript:"));
|
||||
assertTrue(out.contains("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsDisallowedTagsButKeepsText() {
|
||||
String out = HtmlSanitizer.sanitize("<marquee>흐름</marquee><iframe src=\"evil\"></iframe>");
|
||||
assertFalse(out.toLowerCase().contains("<marquee"));
|
||||
assertFalse(out.toLowerCase().contains("<iframe"));
|
||||
assertTrue(out.contains("흐름"), "허용되지 않은 태그도 텍스트 내용은 보존");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAllowedFormattingTags() {
|
||||
String in = "<h2>제목</h2><p>본문 <strong>강조</strong> <em>기울임</em></p><ul><li>항목</li></ul>";
|
||||
String out = HtmlSanitizer.sanitize(in);
|
||||
assertEquals(in, out, "허용 서식 태그는 그대로 유지");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAndBlankPassThrough() {
|
||||
assertNull(HtmlSanitizer.sanitize(null));
|
||||
assertEquals("", HtmlSanitizer.sanitize(""));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.zioinfo.kintex.visitor;
|
||||
|
||||
import com.zioinfo.kintex.visitor.scoring.LeadSignals;
|
||||
import com.zioinfo.kintex.visitor.scoring.RuleBasedLeadScorer;
|
||||
import com.zioinfo.kintex.visitor.scoring.ScoreResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* M10 리드 스코어 규칙 엔진 단위테스트 — 관심도·체류·재방문·결정권자·자료열람 가중 합산의 결정성 검증.
|
||||
* 산식(총점 100 = 관심도 40 + 체류 25 + 재방문 20 + 결정권자 10 + 자료 5).
|
||||
*/
|
||||
class LeadScoringEngineTest {
|
||||
|
||||
private final RuleBasedLeadScorer scorer = new RuleBasedLeadScorer();
|
||||
|
||||
@Test
|
||||
void hotLead_sumsWeightedSignals() {
|
||||
// interest 5(=40) + dwell 240%→clamp(round(140*0.25)=35,25)=25 + revisit 2→clamp(16,20)=16
|
||||
// + 결정권자 10 + 자료 2→clamp(10,5)=5 = 96
|
||||
ScoreResult r = scorer.score(new LeadSignals(5, 240, 2, true, 2));
|
||||
assertEquals(96, r.score());
|
||||
assertEquals("rule", r.source());
|
||||
assertTrue(r.reasons().stream().anyMatch(s -> s.contains("240%")));
|
||||
assertTrue(r.reasons().stream().anyMatch(s -> s.contains("결정권자")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void coldLead_lowInterestOnly() {
|
||||
// interest 1(=8) + dwell 80%→clamp(round(-5),0)=0 + revisit 0 + no dm + no doc = 8
|
||||
ScoreResult r = scorer.score(new LeadSignals(1, 80, 0, false, 0));
|
||||
assertEquals(8, r.score());
|
||||
assertTrue(r.score() >= 0 && r.score() <= 100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scoreIsClampedTo100_andMonotonicInInterest() {
|
||||
// 모든 신호 최대 → 40+25+20+10+5 = 100, 상한 유지.
|
||||
ScoreResult max = scorer.score(new LeadSignals(5, 300, 5, true, 5));
|
||||
assertEquals(100, max.score());
|
||||
|
||||
// 관심도 단조 증가(다른 신호 고정): 1점 < 3점 < 5점.
|
||||
int s1 = scorer.score(LeadSignals.ofInterest(1)).score();
|
||||
int s3 = scorer.score(LeadSignals.ofInterest(3)).score();
|
||||
int s5 = scorer.score(LeadSignals.ofInterest(5)).score();
|
||||
assertTrue(s1 < s3 && s3 < s5, "관심도가 높을수록 스코어가 높아야 한다");
|
||||
assertEquals(8, s1); // 1*8
|
||||
assertEquals(40, s5); // 5*8
|
||||
}
|
||||
|
||||
@Test
|
||||
void outOfRangeInterestIsClamped() {
|
||||
// interest 0·9 → [1,5] 로 클램프(방어).
|
||||
assertEquals(8, scorer.score(new LeadSignals(0, 100, 0, false, 0)).score());
|
||||
assertEquals(40, scorer.score(new LeadSignals(9, 100, 0, false, 0)).score());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package com.zioinfo.kintex.visitor;
|
||||
|
||||
import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinRequest;
|
||||
import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinResult;
|
||||
import com.zioinfo.kintex.visitor.scoring.RuleBasedLeadScorer;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* M10 현장 체크인 중복 방지 단위테스트.
|
||||
* 첫 스캔은 done 처리(alreadyCheckedIn=false), 이미 완료된 배지의 재스캔은 재집계 없이 alreadyCheckedIn=true.
|
||||
*/
|
||||
class VisitorCheckinServiceTest {
|
||||
|
||||
private static final String EVENT = "e-2026-smf";
|
||||
private static final String TOKEN = "QR-ABC123";
|
||||
private static final String RID = "vr-smf-006";
|
||||
|
||||
private VisitorMapper mapper;
|
||||
private VisitorService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(VisitorMapper.class);
|
||||
service = new VisitorService(mapper, new RuleBasedLeadScorer());
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(String state, String checkinAt) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("registrationId", RID);
|
||||
m.put("nameMasked", "한*수");
|
||||
m.put("type", "visitor");
|
||||
m.put("company", "두산로보틱스");
|
||||
m.put("badgeCode", "KTX-V-0006");
|
||||
m.put("badgeIssued", true);
|
||||
m.put("checkinState", state);
|
||||
m.put("checkinAt", checkinAt);
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstScan_marksCheckedIn() {
|
||||
// 토큰 해소 → waiting. 갱신 1행. 재조회 → done.
|
||||
when(mapper.findForCheckin(eq(EVENT), eq(TOKEN), isNull())).thenReturn(row("waiting", null));
|
||||
when(mapper.markCheckedIn(EVENT, RID)).thenReturn(1);
|
||||
when(mapper.findForCheckin(eq(EVENT), isNull(), eq(RID))).thenReturn(row("done", "2026.08.11 09:30"));
|
||||
|
||||
CheckinResult res = service.checkin(EVENT, new CheckinRequest(TOKEN, null));
|
||||
|
||||
assertFalse(res.alreadyCheckedIn(), "첫 체크인은 중복이 아니다");
|
||||
assertEquals("done", res.checkinState());
|
||||
assertEquals("한*수", res.nameMasked()); // 마스킹 필드만 노출
|
||||
verify(mapper, times(1)).markCheckedIn(EVENT, RID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateScan_doesNotRecount() {
|
||||
// 이미 done → markCheckedIn 호출 금지, alreadyCheckedIn=true.
|
||||
when(mapper.findForCheckin(eq(EVENT), eq(TOKEN), isNull())).thenReturn(row("done", "2026.08.11 09:30"));
|
||||
when(mapper.findForCheckin(eq(EVENT), isNull(), eq(RID))).thenReturn(row("done", "2026.08.11 09:30"));
|
||||
|
||||
CheckinResult res = service.checkin(EVENT, new CheckinRequest(TOKEN, null));
|
||||
|
||||
assertTrue(res.alreadyCheckedIn(), "이미 완료된 배지 재스캔은 중복으로 표시");
|
||||
assertEquals("done", res.checkinState());
|
||||
verify(mapper, never()).markCheckedIn(EVENT, RID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void raceCondition_updateReturnsZero_treatedAsAlready() {
|
||||
// 조회 시엔 waiting 이었으나 동시 요청으로 UPDATE 0행 → 이미 완료로 간주(중복).
|
||||
when(mapper.findForCheckin(eq(EVENT), eq(TOKEN), isNull())).thenReturn(row("waiting", null));
|
||||
when(mapper.markCheckedIn(EVENT, RID)).thenReturn(0);
|
||||
when(mapper.findForCheckin(eq(EVENT), isNull(), eq(RID))).thenReturn(row("done", "2026.08.11 09:30"));
|
||||
|
||||
CheckinResult res = service.checkin(EVENT, new CheckinRequest(TOKEN, null));
|
||||
|
||||
assertTrue(res.alreadyCheckedIn());
|
||||
verify(mapper, times(1)).markCheckedIn(EVENT, RID);
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ import { AwardComparePage } from './screens/auction/AwardComparePage';
|
||||
import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoothDashboardPage';
|
||||
import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage';
|
||||
import { LeadScoringPage } from './screens/visitor/LeadScoringPage';
|
||||
import { CheckinDeskPage } from './screens/visitor/CheckinDeskPage';
|
||||
import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage';
|
||||
import { SponsorshipPage } from './screens/marketing/SponsorshipPage';
|
||||
import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage';
|
||||
@ -176,6 +177,7 @@ export function App() {
|
||||
<Route path="/contractor/dashboard" element={<ContractorBoothDashboardPage />} />
|
||||
{/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */}
|
||||
<Route path="/visitors" element={<VisitorRegistrationDashboardPage />} />
|
||||
<Route path="/visitors/checkin" element={<CheckinDeskPage />} />
|
||||
<Route path="/leads" element={<LeadScoringPage />} />
|
||||
<Route path="/campaigns" element={<EdmCampaignPage />} />
|
||||
<Route path="/sponsorship" element={<SponsorshipPage />} />
|
||||
|
||||
@ -42,6 +42,7 @@ const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType<IconProps>;
|
||||
{ key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' },
|
||||
{ key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' },
|
||||
{ key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' },
|
||||
{ key: 'checkin', label: '체크인 데스크', Icon: IconCheckCircle, to: '/visitors/checkin' },
|
||||
{ key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' },
|
||||
{ key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' },
|
||||
{ key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' },
|
||||
|
||||
@ -1,39 +1,45 @@
|
||||
/*
|
||||
* SCR-27 옥션 상세·실시간 순위·응찰 [M15]. Stitch scr_27_auction_detail 이식.
|
||||
* 대상: 장치업체(응찰). 좌 AI 자료 뷰어(배치·설계·BOQ·예상이미지) + 우 실시간 순위·응찰.
|
||||
* ★ M15 엔진 미구현 → SAMPLE_RANKS 정적 데이터. 실 API/WebSocket 없음(카운트다운은 로컬 데모).
|
||||
* 보안(봉인 입찰): 마감 전 경쟁 견적 금액 비공개 — 현재 최저가(공개 벤치마크)와 내 견적만 노출,
|
||||
* 그 외 타사 금액은 "비공개" 마스킹. "익명 순위" 토글로 순위만 확인.
|
||||
* 응찰은 등록업체 게이트(안내) + 로컬 상태 데모("백엔드 연동 예정").
|
||||
* SCR-27 옥션 상세·실시간 순위·응찰 [M15]. 실 API 전환(폴링).
|
||||
* 정본: GET /api/auctions/{id} (AuctionDetail) · POST /{id}/bids · POST /{id}/close.
|
||||
* 대상: 장치업체(응찰)·발주자. 좌 AI 자료 뷰어 + 우 봉인 순위·응찰.
|
||||
* ★ 봉인 입찰(서버 강제): 응답의 ranking 은 이미 마스킹됨 — 타사 금액/업체명 없음.
|
||||
* 화면은 서버가 준 priceMasked/price 만 렌더(클라이언트 언마스킹 없음). refetchInterval 5초.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
import { ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons';
|
||||
import {
|
||||
REFERENCE_TABS,
|
||||
SAMPLE_RANKS,
|
||||
auctionApi,
|
||||
formatWon,
|
||||
type AuctionDetail,
|
||||
type MaterialKind,
|
||||
type RankRow,
|
||||
} from './sampleAuction';
|
||||
import { SampleBadge, useToast } from './aucShared';
|
||||
} from './auctionApi';
|
||||
import { errMessage, useToast } from './aucShared';
|
||||
import './auction.css';
|
||||
|
||||
const REF_DESC: Record<MaterialKind, { title: string; desc: string; ai?: boolean }> = {
|
||||
const REF_DESC: Record<MaterialKind, { label: string; title: string; desc: string; ai?: boolean }> = {
|
||||
layout: {
|
||||
label: '배치도',
|
||||
title: '배치도 (M2)',
|
||||
desc: 'A-102 부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.',
|
||||
desc: '부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.',
|
||||
},
|
||||
design: {
|
||||
label: '부스 설계안',
|
||||
title: '부스 설계안 (M3)',
|
||||
desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.',
|
||||
},
|
||||
boq: {
|
||||
label: '물량서(BOQ)',
|
||||
title: '물량서 (M4 · BOQ)',
|
||||
desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.',
|
||||
},
|
||||
aiimage: {
|
||||
label: '예상 이미지',
|
||||
title: '예상 이미지 (M5)',
|
||||
desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.',
|
||||
ai: true,
|
||||
@ -41,42 +47,116 @@ const REF_DESC: Record<MaterialKind, { title: string; desc: string; ai?: boolean
|
||||
};
|
||||
|
||||
export function AuctionDetailPage() {
|
||||
const { auctionId = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const [tab, setTab] = useState<MaterialKind>('aiimage');
|
||||
const [tab, setTab] = useState<MaterialKind>('layout');
|
||||
const [anon, setAnon] = useState(true);
|
||||
const [seconds, setSeconds] = useState(5076); // 01:24:36
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [bidPrice, setBidPrice] = useState('');
|
||||
const [leadDays, setLeadDays] = useState('');
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['auction', auctionId],
|
||||
queryFn: () => auctionApi.detail(auctionId),
|
||||
enabled: !!auctionId,
|
||||
retry: false,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const detail = q.data;
|
||||
|
||||
// 카운트다운(로컬 tick) — deadline 기준. detail 갱신 시 재동기화.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setSeconds((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||||
if (!detail?.deadline) return;
|
||||
const target = new Date(detail.deadline).getTime();
|
||||
const sync = () => setSeconds(Math.max(0, Math.floor((target - Date.now()) / 1000)));
|
||||
sync();
|
||||
const id = window.setInterval(sync, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
}, [detail?.deadline]);
|
||||
|
||||
// 자료 탭 초기값 = 존재하는 첫 자료
|
||||
useEffect(() => {
|
||||
if (detail && detail.materials.length && !detail.materials.includes(tab)) {
|
||||
setTab(detail.materials[0]);
|
||||
}
|
||||
}, [detail, tab]);
|
||||
|
||||
const bidM = useMutation({
|
||||
mutationFn: () =>
|
||||
auctionApi.bid(auctionId, {
|
||||
total: Number(bidPrice),
|
||||
leadDays: leadDays ? Number(leadDays) : undefined,
|
||||
}),
|
||||
onSuccess: (r) => {
|
||||
show(`응찰 완료 — 현재 ${r.myRank ?? '-'}위 (v${r.version})`);
|
||||
setBidPrice('');
|
||||
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const closeM = useMutation({
|
||||
mutationFn: () => auctionApi.close(auctionId),
|
||||
onSuccess: () => {
|
||||
show('라운드를 마감했습니다. 봉인이 해제되어 견적 비교가 가능합니다.');
|
||||
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
if (q.isLoading) {
|
||||
return (
|
||||
<div className="kx-page kx-auc">
|
||||
<Skeleton height={64} radius={12} />
|
||||
<div style={{ height: 16 }} />
|
||||
<Skeleton height={420} radius={16} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (q.isError || !detail) {
|
||||
return (
|
||||
<div className="kx-page kx-auc">
|
||||
<ErrorState message="옥션 상세를 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active = REF_DESC[tab];
|
||||
const closed = detail.status !== '진행중';
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<div className="kx-page kx-auc">
|
||||
{/* 상단 컨텍스트 바 */}
|
||||
<div className="kx-aucd__bar">
|
||||
<div className="kx-aucd__bar-left">
|
||||
{!closed && (
|
||||
<span className="kx-live">
|
||||
<span className="kx-live__dot" aria-hidden="true" />
|
||||
실시간
|
||||
</span>
|
||||
)}
|
||||
<h1 className="kx-auc__title" style={{ fontSize: 'var(--fs-h2)' }}>
|
||||
A-102 독립부스 시공
|
||||
{detail.title}
|
||||
</h1>
|
||||
<span className="kx-tag kx-tag--type">역경매</span>
|
||||
<span className="kx-tag kx-tag--muted">라운드 2</span>
|
||||
<SampleBadge />
|
||||
<span className="kx-tag kx-tag--type">{detail.type}</span>
|
||||
<span className="kx-tag kx-tag--muted">라운드 {detail.round}</span>
|
||||
</div>
|
||||
<div className="kx-aucd__bar-metrics">
|
||||
<div className="kx-aucd__metric">
|
||||
<span className="kx-aucd__metric-label">상태</span>
|
||||
<span className="kx-aucd__metric-value kx-aucd__metric-value--ok">활성 응찰 중</span>
|
||||
<span
|
||||
className={`kx-aucd__metric-value ${!closed ? 'kx-aucd__metric-value--ok' : ''}`}
|
||||
>
|
||||
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kx-aucd__metric">
|
||||
<span className="kx-aucd__metric-label">현재 최저가</span>
|
||||
<span className="kx-aucd__metric-value tnum">{formatWon(8_400_000)}</span>
|
||||
<span className="kx-aucd__metric-value tnum">
|
||||
{detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -85,15 +165,15 @@ export function AuctionDetailPage() {
|
||||
{/* 좌 — AI 자료 뷰어 */}
|
||||
<section className="kx-viewer" aria-label="AI 설계 자료 뷰어">
|
||||
<div className="kx-viewer__tabs" role="tablist" aria-label="자료 종류">
|
||||
{REFERENCE_TABS.map((t) => (
|
||||
{detail.materials.map((kind) => (
|
||||
<button
|
||||
key={t.kind}
|
||||
key={kind}
|
||||
role="tab"
|
||||
aria-selected={tab === t.kind}
|
||||
className={`kx-viewer__tab ${tab === t.kind ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(t.kind)}
|
||||
aria-selected={tab === kind}
|
||||
className={`kx-viewer__tab ${tab === kind ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(kind)}
|
||||
>
|
||||
{t.label}
|
||||
{REF_DESC[kind].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -110,7 +190,7 @@ export function AuctionDetailPage() {
|
||||
type="button"
|
||||
className="kx-viewer__tab"
|
||||
style={{ color: 'rgba(255,255,255,0.8)', height: 'auto' }}
|
||||
onClick={() => show('전체화면 뷰어 — 백엔드 연동 예정')}
|
||||
onClick={() => show('전체화면 뷰어는 자료 스토리지 연동 후 제공됩니다.')}
|
||||
aria-label="전체화면"
|
||||
>
|
||||
<IconExpand size={18} />
|
||||
@ -137,30 +217,106 @@ export function AuctionDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="kx-auc__gate kx-auc__gate--seal" style={{ margin: '0 16px' }}>
|
||||
봉인 입찰 — 마감 전 경쟁 견적 금액은 비공개, 최저가만 공개됩니다.
|
||||
{detail.sealed
|
||||
? '봉인 입찰 — 마감 전 경쟁 견적 금액은 비공개, 최저가만 공개됩니다.'
|
||||
: '마감됨 — 발주자 견적 비교에서 전체가 공개됩니다.'}
|
||||
</div>
|
||||
|
||||
<div className="kx-rank__list">
|
||||
{SAMPLE_RANKS.map((r) => (
|
||||
<RankRowItem key={r.rank} row={r} anon={anon} />
|
||||
))}
|
||||
{detail.ranking.length === 0 ? (
|
||||
<p className="kx-rank__note" style={{ padding: '8px 16px' }}>
|
||||
아직 응찰이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
detail.ranking.map((r) => <RankRowItem key={r.rank} row={r} anon={anon} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kx-rank__foot">
|
||||
{!closed && (
|
||||
<div className="kx-countdown">
|
||||
<span className="kx-countdown__label">라운드 종료까지</span>
|
||||
<span className="kx-countdown__value">{fmtClock(seconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.canBid ? (
|
||||
<>
|
||||
<div className="kx-grid-2" style={{ marginBottom: 8 }}>
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="bid-price">
|
||||
응찰 총액(부가세 별도)
|
||||
</label>
|
||||
<input
|
||||
id="bid-price"
|
||||
className="kx-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={bidPrice}
|
||||
placeholder="예: 8400000"
|
||||
onChange={(e) => setBidPrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="bid-lead">
|
||||
납기(일)
|
||||
</label>
|
||||
<input
|
||||
id="bid-lead"
|
||||
className="kx-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={leadDays}
|
||||
placeholder="예: 12"
|
||||
onChange={(e) => setLeadDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
leadingIcon={<IconPlus size={16} />}
|
||||
onClick={() => show('견적서 작성(SCR-28) — 백엔드 연동 예정')}
|
||||
onClick={() => {
|
||||
if (!bidPrice || Number(bidPrice) <= 0) return show('응찰 금액을 입력해 주세요.');
|
||||
bidM.mutate();
|
||||
}}
|
||||
disabled={bidM.isPending}
|
||||
>
|
||||
견적서 작성 · 재응찰
|
||||
{bidM.isPending ? '제출 중…' : '견적서 제출 · 재응찰'}
|
||||
</Button>
|
||||
<p className="kx-rank__note">
|
||||
재응찰 시 이전 응찰 정보는 자동 대체됩니다. 킨텍스 등록업체만 응찰할 수 있습니다.
|
||||
재응찰 시 이전 응찰 정보는 자동 대체됩니다(버전 증가). 킨텍스 등록업체만 응찰할 수
|
||||
있습니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="kx-rank__note">
|
||||
{closed
|
||||
? '마감된 옥션입니다. 응찰이 종료되었습니다.'
|
||||
: '응찰 권한이 없습니다 — 킨텍스 등록 장치업체(초대 대상)만 응찰할 수 있습니다.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.canViewAward && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
block
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => navigate(`/auctions/${auctionId}/award`)}
|
||||
>
|
||||
견적 비교·낙찰로 이동
|
||||
</Button>
|
||||
)}
|
||||
{!closed && detail.canViewAward === false && isOrdererHint(detail) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
block
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => closeM.mutate()}
|
||||
disabled={closeM.isPending}
|
||||
>
|
||||
{closeM.isPending ? '마감 중…' : '라운드 마감(발주자)'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@ -169,17 +325,22 @@ export function AuctionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 발주자이면서 아직 마감 전이라 close 버튼을 노출할지 힌트. canBid=false + !canViewAward + 응찰 불가 상태에서 노출. */
|
||||
function isOrdererHint(d: AuctionDetail): boolean {
|
||||
// 발주자 판단은 서버가 canViewAward(마감 후)로만 노출하므로, 마감 전에는 close 버튼을 항상 시도 가능하게 둔다
|
||||
// (권한 없으면 서버가 403 → 토스트). 응찰 가능한 업체에는 노출하지 않는다.
|
||||
return !d.canBid;
|
||||
}
|
||||
|
||||
function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) {
|
||||
// 봉인 규칙: 내 견적 + 현재 최저가(공개 벤치마크)만 금액 노출, 그 외 타사는 마스킹.
|
||||
const revealPrice = row.isMe || row.isLowest;
|
||||
const cls = row.isMe ? 'kx-rank__row--me' : row.isLowest ? 'kx-rank__row--lowest' : '';
|
||||
const name = anon ? (row.isMe ? '나의 응찰 (ME)' : row.alias) : row.isMe ? '나의 응찰 (ME)' : `(주)협력사 ${row.rank}`;
|
||||
const name = row.isMe ? '나의 응찰 (ME)' : anon ? row.alias : `(주)협력사 ${row.rank}`;
|
||||
return (
|
||||
<div className={`kx-rank__row ${cls}`}>
|
||||
<span className="kx-rank__badge">{row.rank}</span>
|
||||
<div className="kx-rank__main">
|
||||
<p className="kx-rank__alias">{name}</p>
|
||||
{revealPrice ? (
|
||||
{!row.priceMasked && row.price != null ? (
|
||||
<p className="kx-rank__price tnum">{formatWon(row.price)}</p>
|
||||
) : (
|
||||
<p className="kx-rank__price--masked" title="봉인 입찰 — 마감 후 공개">
|
||||
@ -189,7 +350,10 @@ function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) {
|
||||
</div>
|
||||
{row.isLowest && <span className="kx-rank__tag">최저가</span>}
|
||||
{row.isMe && (
|
||||
<span className="kx-rank__tag" style={{ background: 'var(--color-ai-surface)', color: 'var(--color-ai-accent)' }}>
|
||||
<span
|
||||
className="kx-rank__tag"
|
||||
style={{ background: 'var(--color-ai-surface)', color: 'var(--color-ai-accent)' }}
|
||||
>
|
||||
나의 순위
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -1,42 +1,45 @@
|
||||
/*
|
||||
* SCR-26 공사/장치 옥션 목록·개설 [M15]. Stitch scr_26_auction_list 이식.
|
||||
* SCR-26 공사/장치 옥션 목록·개설 [M15]. 실 API 전환.
|
||||
* 정본: GET /api/auctions (AuctionSummary[]) · POST /api/auctions (개설 — 발주자 권한, 서버 검증).
|
||||
* 대상: 참가업체·주최자(발주). 좌 옥션 카드 목록 + 우 옥션 개설 패널.
|
||||
* ★ M15 엔진 미구현 → SAMPLE_AUCTIONS 정적 데이터("샘플 데이터" 배지). 실 API fetch 없음.
|
||||
* 개설·초대는 로컬 상태 데모 + "백엔드 연동 예정" 안내.
|
||||
* 보안: 등록업체만 초대 가능(verified 게이트) — 미등록 업체 체크박스 비활성.
|
||||
* 보안: 개설·초대는 등록업체 게이트가 서버에서 강제(미등록 초대 id 는 서버가 필터). 발주 권한 없으면 403.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { DdayChip } from '../../components/ui/Badge';
|
||||
import { EmptyState } from '../../components/ui/States';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDocument,
|
||||
IconFloorplan,
|
||||
IconImage,
|
||||
IconSpark,
|
||||
IconSettings,
|
||||
} from '../../components/ui/icons';
|
||||
import {
|
||||
INVITABLE_COMPANIES,
|
||||
MATERIAL_LABEL,
|
||||
SAMPLE_AUCTIONS,
|
||||
auctionApi,
|
||||
formatWon,
|
||||
type AuctionStatus,
|
||||
type AuctionSummary,
|
||||
type AwardCriteria,
|
||||
type AuctionType,
|
||||
type MaterialKind,
|
||||
} from './sampleAuction';
|
||||
import { SampleBadge, useToast } from './aucShared';
|
||||
} from './auctionApi';
|
||||
import { errMessage, useToast } from './aucShared';
|
||||
import './auction.css';
|
||||
|
||||
type TabKey = 'live' | 'closed' | 'mine';
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'live', label: '진행중' },
|
||||
{ key: 'closed', label: '마감' },
|
||||
{ key: 'mine', label: '내가 개설' },
|
||||
{ key: 'mine', label: '역경매' },
|
||||
];
|
||||
|
||||
const CATEGORIES = ['전시디자인설치', '구조물 임대', '전기/조명', '영상/음향', '네트워크'];
|
||||
const MATERIAL_KINDS: MaterialKind[] = ['layout', 'design', 'boq', 'aiimage'];
|
||||
|
||||
const MAT_ICON: Record<MaterialKind, ReactNode> = {
|
||||
layout: <IconFloorplan size={16} />,
|
||||
design: <IconSettings size={16} />,
|
||||
@ -45,28 +48,77 @@ const MAT_ICON: Record<MaterialKind, ReactNode> = {
|
||||
};
|
||||
|
||||
export function AuctionListPage() {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { show, node: toast } = useToast();
|
||||
const [tab, setTab] = useState<TabKey>('live');
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['auctions'],
|
||||
queryFn: () => auctionApi.list(),
|
||||
retry: false,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const auctions = q.data ?? [];
|
||||
|
||||
// 개설 폼 상태
|
||||
const eventIds = useMemo(
|
||||
() => Array.from(new Set(auctions.map((a) => a.eventId))).filter(Boolean),
|
||||
[auctions],
|
||||
);
|
||||
const [eventId, setEventId] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState(CATEGORIES[0]);
|
||||
const [type, setType] = useState<AuctionType>('역경매');
|
||||
const [criteria, setCriteria] = useState<AwardCriteria>('comprehensive');
|
||||
const [weights, setWeights] = useState({ price: 60, reputation: 25, delivery: 15 });
|
||||
const [invited, setInvited] = useState<Record<string, boolean>>({
|
||||
'(주)에이치디자인': true,
|
||||
const [round, setRound] = useState(1);
|
||||
const [deadline, setDeadline] = useState('');
|
||||
const [materials, setMaterials] = useState<Set<MaterialKind>>(new Set(MATERIAL_KINDS));
|
||||
|
||||
const effectiveEventId = eventId || eventIds[0] || '';
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: () =>
|
||||
auctionApi.create({
|
||||
eventId: effectiveEventId,
|
||||
title: title.trim(),
|
||||
category,
|
||||
type,
|
||||
awardCriteria: criteria,
|
||||
weights,
|
||||
round,
|
||||
deadline,
|
||||
materials: Array.from(materials),
|
||||
invitedCompanyIds: [], // 공개 옥션(등록업체 응찰). 초대 세분은 후속.
|
||||
}),
|
||||
onSuccess: () => {
|
||||
show('옥션 공고를 게시했습니다.');
|
||||
setTitle('');
|
||||
setDeadline('');
|
||||
qc.invalidateQueries({ queryKey: ['auctions'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (tab === 'closed') return SAMPLE_AUCTIONS.filter((a) => a.status !== '진행중');
|
||||
if (tab === 'mine') return SAMPLE_AUCTIONS.filter((a) => a.type === '역경매');
|
||||
return SAMPLE_AUCTIONS.filter((a) => a.status === '진행중');
|
||||
}, [tab]);
|
||||
if (tab === 'closed') return auctions.filter((a) => a.status !== '진행중');
|
||||
if (tab === 'mine') return auctions.filter((a) => a.type === '역경매');
|
||||
return auctions.filter((a) => a.status === '진행중');
|
||||
}, [tab, auctions]);
|
||||
|
||||
function submitCreate() {
|
||||
if (!effectiveEventId) return show('행사를 선택해 주세요.');
|
||||
if (!title.trim()) return show('공고명을 입력해 주세요.');
|
||||
if (!deadline) return show('마감 기한을 선택해 주세요.');
|
||||
createM.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<div className="kx-page kx-auc">
|
||||
<header className="kx-auc__head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="kx-auc__title">공사·장치 옥션</h1>
|
||||
<SampleBadge />
|
||||
</div>
|
||||
<p className="kx-auc__subtitle">AI 설계자료(M2~M5) 기반 역경매 · 등록업체 응찰</p>
|
||||
</div>
|
||||
<div className="kx-auc__head-actions">
|
||||
@ -89,14 +141,26 @@ export function AuctionListPage() {
|
||||
<div className="kx-auc__split">
|
||||
{/* 좌 — 옥션 카드 목록 */}
|
||||
<section className="kx-auc__list" aria-label="옥션 목록">
|
||||
{visible.length === 0 ? (
|
||||
{q.isLoading && (
|
||||
<>
|
||||
<Skeleton height={168} radius={16} />
|
||||
<Skeleton height={168} radius={16} />
|
||||
</>
|
||||
)}
|
||||
{q.isError && !q.isLoading && (
|
||||
<ErrorState message="옥션 목록을 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||
)}
|
||||
{!q.isLoading && !q.isError && visible.length === 0 && (
|
||||
<EmptyState
|
||||
title="진행 중인 옥션이 없습니다"
|
||||
title="해당 조건의 옥션이 없습니다"
|
||||
description="새로운 시공 옥션을 개설해 협력사를 모집하세요."
|
||||
/>
|
||||
) : (
|
||||
visible.map((a) => <AuctionCard key={a.id} auction={a} onOpen={() => show(`${a.title} 상세 — 백엔드 연동 예정`)} />)
|
||||
)}
|
||||
{!q.isLoading &&
|
||||
!q.isError &&
|
||||
visible.map((a) => (
|
||||
<AuctionCard key={a.id} auction={a} onOpen={() => navigate(`/auctions/${a.id}`)} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 우 — 옥션 개설 패널 */}
|
||||
@ -109,18 +173,77 @@ export function AuctionListPage() {
|
||||
</div>
|
||||
<div className="kx-auc-open__body">
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="auc-cat">
|
||||
카테고리 선택
|
||||
<label className="kx-field__label" htmlFor="auc-event">
|
||||
행사 선택
|
||||
</label>
|
||||
<select id="auc-cat" className="kx-input">
|
||||
<option>전시디자인설치</option>
|
||||
<option>구조물 임대</option>
|
||||
<option>전기/조명</option>
|
||||
<option>영상/음향</option>
|
||||
<option>네트워크</option>
|
||||
<select
|
||||
id="auc-event"
|
||||
className="kx-input"
|
||||
value={effectiveEventId}
|
||||
onChange={(e) => setEventId(e.target.value)}
|
||||
>
|
||||
{eventIds.length === 0 && <option value="">행사 없음</option>}
|
||||
{eventIds.map((eid) => (
|
||||
<option key={eid} value={eid}>
|
||||
{eid}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="auc-title">
|
||||
공고명
|
||||
</label>
|
||||
<input
|
||||
id="auc-title"
|
||||
className="kx-input"
|
||||
type="text"
|
||||
value={title}
|
||||
placeholder="예: A-102 독립부스 시공"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="kx-grid-2">
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="auc-cat">
|
||||
카테고리
|
||||
</label>
|
||||
<select
|
||||
id="auc-cat"
|
||||
className="kx-input"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="kx-field">
|
||||
<span className="kx-field__label">옥션 유형</span>
|
||||
<div className="kx-seg" role="tablist" aria-label="옥션 유형">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={type === '역경매'}
|
||||
className={`kx-seg__btn ${type === '역경매' ? 'is-active' : ''}`}
|
||||
onClick={() => setType('역경매')}
|
||||
>
|
||||
역경매
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={type === 'RFQ'}
|
||||
className={`kx-seg__btn ${type === 'RFQ' ? 'is-active' : ''}`}
|
||||
onClick={() => setType('RFQ')}
|
||||
>
|
||||
RFQ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-field">
|
||||
<span className="kx-field__label">낙찰 기준 (Award Criteria)</span>
|
||||
<div className="kx-seg" role="tablist" aria-label="낙찰 기준">
|
||||
@ -168,66 +291,64 @@ export function AuctionListPage() {
|
||||
<label className="kx-field__label" htmlFor="auc-round">
|
||||
차수 (Round)
|
||||
</label>
|
||||
<input id="auc-round" className="kx-input" type="text" defaultValue="1차" />
|
||||
<input
|
||||
id="auc-round"
|
||||
className="kx-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={round}
|
||||
onChange={(e) => setRound(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</div>
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="auc-deadline">
|
||||
마감 기한
|
||||
</label>
|
||||
<input id="auc-deadline" className="kx-input" type="date" />
|
||||
<input
|
||||
id="auc-deadline"
|
||||
className="kx-input"
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-field">
|
||||
<span className="kx-field__label">설계 데이터 연동</span>
|
||||
<button
|
||||
type="button"
|
||||
className="kx-auc-attach"
|
||||
onClick={() => show('M2~M5 자료 선택기 — 백엔드 연동 예정')}
|
||||
>
|
||||
<IconSpark size={26} />
|
||||
M2~M5 자료 첨부
|
||||
<small>배치도 · 부스 설계안 · 물량서(BOQ) · AI 예상 이미지 포함</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label">
|
||||
협력사 초대
|
||||
<span className="kx-field__label-note">등록업체만 초대 가능</span>
|
||||
</label>
|
||||
<div className="kx-invite">
|
||||
{INVITABLE_COMPANIES.map((c) => (
|
||||
<label
|
||||
key={c.name}
|
||||
className={`kx-invite__row ${c.verified ? '' : 'kx-invite__row--blocked'}`}
|
||||
>
|
||||
<span>{c.name}</span>
|
||||
{c.verified ? (
|
||||
<span className="kx-field__label">설계 데이터 연동 (M2~M5)</span>
|
||||
<div className="kx-invite" role="group" aria-label="첨부 자료">
|
||||
{MATERIAL_KINDS.map((m) => (
|
||||
<label key={m} className="kx-invite__row">
|
||||
<span>{MATERIAL_LABEL[m]}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!invited[c.name]}
|
||||
checked={materials.has(m)}
|
||||
onChange={(e) =>
|
||||
setInvited((prev) => ({ ...prev, [c.name]: e.target.checked }))
|
||||
setMaterials((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (e.target.checked) next.add(m);
|
||||
else next.delete(m);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
aria-label={`${c.name} 초대`}
|
||||
aria-label={`${MATERIAL_LABEL[m]} 첨부`}
|
||||
/>
|
||||
) : (
|
||||
<span className="kx-invite__blocked-tag" title="킨텍스 미등록 업체 — 응찰 불가">
|
||||
미등록 · 차단
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-auc__gate">
|
||||
킨텍스 등록업체만 응찰할 수 있습니다. 미등록 업체 초대는 서버에서 차단됩니다.
|
||||
</div>
|
||||
|
||||
<Button
|
||||
block
|
||||
leadingIcon={<IconPlus size={16} />}
|
||||
onClick={() => show('옥션 공고 게시 — 백엔드 연동 예정')}
|
||||
onClick={submitCreate}
|
||||
disabled={createM.isPending}
|
||||
>
|
||||
옥션 공고 게시
|
||||
{createM.isPending ? '게시 중…' : '옥션 공고 게시'}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
@ -319,7 +440,6 @@ function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: ()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!closed && (
|
||||
<div className="kx-auc-card__foot">
|
||||
<div className="kx-auc-card__mats">
|
||||
{auction.materials.map((m) => (
|
||||
@ -333,7 +453,6 @@ function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: ()
|
||||
상세보기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,45 +1,107 @@
|
||||
/*
|
||||
* SCR-29 낙찰 비교·선정(Award) [M15]. Stitch scr_29_award_compare 이식.
|
||||
* SCR-29 낙찰 비교·선정(Award) [M15]. 실 API 전환.
|
||||
* 정본: GET /api/auctions/{id}/award-view (마감 후 발주자 전용, 봉인 해제) · POST /{id}/award.
|
||||
* 대상: 발주자(주최자·참가업체). 마감 후 견적서 비교 → 낙찰 → 계약·발주 전환.
|
||||
* ★ M15 엔진 미구현 → SAMPLE_QUOTES 정적 데이터. 낙찰 승인은 로컬 상태 데모("백엔드 연동 예정").
|
||||
* 보안: 마감 후 발주자 뷰이므로 전 견적 공개(봉인 해제). 낙찰 사유 필수 입력 후 승인.
|
||||
* 보안: 마감 전 또는 비발주자는 서버가 403 → 화면은 접근 불가 안내. 낙찰 사유 필수(서버 검증).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
import { EmptyState } from '../../components/ui/States';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconAiBot, IconCheckCircle, IconDownload, IconWarning } from '../../components/ui/icons';
|
||||
import { RISK_BARS, SAMPLE_QUOTES, formatWon, type QuoteColumn } from './sampleAuction';
|
||||
import { SampleBadge, useToast } from './aucShared';
|
||||
import { auctionApi, formatWon, type Quote } from './auctionApi';
|
||||
import { errMessage, useToast } from './aucShared';
|
||||
import './auction.css';
|
||||
|
||||
export function AwardComparePage() {
|
||||
const { show, node: toast } = useToast();
|
||||
const quotes = SAMPLE_QUOTES;
|
||||
const recommended = quotes.find((q) => q.recommended) ?? quotes[0];
|
||||
const [reason, setReason] = useState('');
|
||||
const [awarded, setAwarded] = useState(false);
|
||||
/** 인라인 별점 아이콘(★ 텍스트 글리프 대체 — stroke SVG). */
|
||||
function IconStar({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const onAward = () => {
|
||||
if (!reason.trim()) {
|
||||
show('선정 사유를 입력해 주세요.');
|
||||
return;
|
||||
export function AwardComparePage() {
|
||||
const { auctionId = '' } = useParams();
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const [reason, setReason] = useState('');
|
||||
const [selectedBidId, setSelectedBidId] = useState<string | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['award-view', auctionId],
|
||||
queryFn: () => auctionApi.awardView(auctionId),
|
||||
enabled: !!auctionId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const view = q.data;
|
||||
const quotes = view?.quotes ?? [];
|
||||
const recommended = useMemo(() => quotes.find((x) => x.recommended) ?? quotes[0], [quotes]);
|
||||
const alreadyAwarded = !!view?.awarded;
|
||||
const selected = quotes.find((x) => x.bidId === selectedBidId) ?? recommended;
|
||||
|
||||
const awardM = useMutation({
|
||||
mutationFn: () =>
|
||||
auctionApi.award(auctionId, { bidId: selected!.bidId, reason: reason.trim() }),
|
||||
onSuccess: (r) => {
|
||||
show(`${r.companyName ?? '선정 업체'} 낙찰 승인 완료 — 계약·발주 전환 대상입니다.`);
|
||||
qc.invalidateQueries({ queryKey: ['award-view', auctionId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
function onAward() {
|
||||
if (!selected) return show('선정할 견적을 선택해 주세요.');
|
||||
if (!reason.trim()) return show('선정 사유를 입력해 주세요.');
|
||||
awardM.mutate();
|
||||
}
|
||||
|
||||
if (q.isLoading) {
|
||||
return (
|
||||
<div className="kx-page kx-auc">
|
||||
<Skeleton height={64} radius={12} />
|
||||
<div style={{ height: 16 }} />
|
||||
<Skeleton height={360} radius={16} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (q.isError) {
|
||||
const msg =
|
||||
(q.error as { code?: string })?.code === 'FORBIDDEN'
|
||||
? '마감 후 발주자만 견적 비교·낙찰을 열람할 수 있습니다.'
|
||||
: '견적 비교 정보를 불러오지 못했습니다.';
|
||||
return (
|
||||
<div className="kx-page kx-auc">
|
||||
<ErrorState message={msg} onRetry={() => q.refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
setAwarded(true);
|
||||
show(`${recommended.alias} 낙찰 승인 — 계약·발주 전환은 백엔드 연동 예정`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<div className="kx-page kx-auc">
|
||||
<header className="kx-auc__head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="kx-auc__title">역경매 응찰 비교 및 낙찰</h1>
|
||||
<SampleBadge />
|
||||
<p className="kx-auc__subtitle">
|
||||
{view?.auctionTitle ?? ''} · 마감 후 발주자 뷰(봉인 해제)
|
||||
</p>
|
||||
</div>
|
||||
<p className="kx-auc__subtitle">A-102 독립부스 시공 · 마감 후 발주자 뷰(봉인 해제)</p>
|
||||
</div>
|
||||
<Button variant="secondary" leadingIcon={<IconDownload size={16} />} onClick={() => show('응찰서 원본 다운로드 — 백엔드 연동 예정')}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leadingIcon={<IconDownload size={16} />}
|
||||
onClick={() => show('응찰서 원본 다운로드는 견적서 PDF 산출 연동 후 제공됩니다.')}
|
||||
>
|
||||
응찰서 원본 다운로드
|
||||
</Button>
|
||||
</header>
|
||||
@ -47,15 +109,19 @@ export function AwardComparePage() {
|
||||
<div className="kx-award__summary">
|
||||
<div className="kx-award__chip">
|
||||
<span className="kx-award__chip-label">공고명</span>
|
||||
<span className="kx-award__chip-value">A-102 독립부스 시공</span>
|
||||
<span className="kx-award__chip-value">{view?.auctionTitle ?? '—'}</span>
|
||||
</div>
|
||||
<div className="kx-award__chip">
|
||||
<span className="kx-award__chip-label">응찰 현황</span>
|
||||
<span className="kx-award__chip-value">총 {quotes.length}개사 참여</span>
|
||||
<span className="kx-award__chip-value">총 {view?.bidderCount ?? 0}개사 참여</span>
|
||||
</div>
|
||||
<div className="kx-award__chip">
|
||||
<span className="kx-award__chip-label">낙찰 기준</span>
|
||||
<span className="kx-award__chip-value">종합평가 (가격 40% + 역량 60%)</span>
|
||||
<span className="kx-award__chip-value">
|
||||
{view?.awardCriteria === 'lowest'
|
||||
? '최저가'
|
||||
: `종합평가 (가격 ${view?.weights.price ?? 0}% · 평판 ${view?.weights.reputation ?? 0}% · 납기 ${view?.weights.delivery ?? 0}%)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -75,14 +141,21 @@ export function AwardComparePage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="kx-compare__metric-col">비교 항목</th>
|
||||
{quotes.map((q) => (
|
||||
{quotes.map((qc2) => (
|
||||
<th
|
||||
key={q.alias}
|
||||
className={`kx-compare__co ${q.recommended ? 'kx-compare__co--rec' : ''}`}
|
||||
key={qc2.bidId}
|
||||
className={`kx-compare__co ${qc2.bidId === selected?.bidId ? 'kx-compare__co--rec' : ''}`}
|
||||
>
|
||||
{q.recommended && <span className="kx-compare__rec-tag">최적 추천</span>}
|
||||
<span className="kx-compare__co-name">{q.alias}</span>
|
||||
<span className="kx-compare__co-sub">{q.subtitle}</span>
|
||||
{qc2.recommended && <span className="kx-compare__rec-tag">최적 추천</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="kx-compare__co-select"
|
||||
aria-pressed={qc2.bidId === selected?.bidId}
|
||||
onClick={() => setSelectedBidId(qc2.bidId)}
|
||||
>
|
||||
<span className="kx-compare__co-name">{qc2.alias}</span>
|
||||
<span className="kx-compare__co-sub">{qc2.subtitle}</span>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@ -90,23 +163,25 @@ export function AwardComparePage() {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="kx-compare__metric-col">총액 (VAT 별도)</td>
|
||||
{quotes.map((q) => (
|
||||
<td key={q.alias} className={q.isLowestPrice ? 'kx-compare__best' : ''}>
|
||||
<span className="kx-compare__val-strong tnum">{formatWon(q.totalPrice)}</span>
|
||||
{quotes.map((qc2) => (
|
||||
<td key={qc2.bidId} className={qc2.isLowestPrice ? 'kx-compare__best' : ''}>
|
||||
<span className="kx-compare__val-strong tnum">
|
||||
{formatWon(qc2.totalPrice)}
|
||||
</span>
|
||||
<span
|
||||
className={`kx-compare__note ${q.isLowestPrice ? 'kx-compare__note--best' : ''}`}
|
||||
className={`kx-compare__note ${qc2.isLowestPrice ? 'kx-compare__note--best' : ''}`}
|
||||
>
|
||||
{q.isLowestPrice ? '▼ 최저가' : `+${q.priceDeltaPct}% 차이`}
|
||||
{qc2.isLowestPrice ? '최저가' : `+${qc2.priceDeltaPct}% 차이`}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="kx-compare__metric-col">납기 (시공 완료)</td>
|
||||
{quotes.map((q) => (
|
||||
<td key={q.alias} className={q.isShortestLead ? 'kx-compare__best' : ''}>
|
||||
<span className="tnum">{q.leadDays}일</span>
|
||||
{q.isShortestLead && (
|
||||
{quotes.map((qc2) => (
|
||||
<td key={qc2.bidId} className={qc2.isShortestLead ? 'kx-compare__best' : ''}>
|
||||
<span className="tnum">{qc2.leadDays}일</span>
|
||||
{qc2.isShortestLead && (
|
||||
<span className="kx-compare__note kx-compare__note--best">최단 납기</span>
|
||||
)}
|
||||
</td>
|
||||
@ -114,27 +189,33 @@ export function AwardComparePage() {
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="kx-compare__metric-col">업체 평판 (최근 1년)</td>
|
||||
{quotes.map((q) => {
|
||||
const best = q.reputation === Math.max(...quotes.map((x) => x.reputation));
|
||||
{quotes.map((qc2) => {
|
||||
const best =
|
||||
qc2.reputation === Math.max(...quotes.map((x) => x.reputation));
|
||||
return (
|
||||
<td key={q.alias} className={best ? 'kx-compare__best' : ''}>
|
||||
<span className="kx-compare__star" aria-hidden="true">
|
||||
★
|
||||
<td key={qc2.bidId} className={best ? 'kx-compare__best' : ''}>
|
||||
<span className="kx-compare__star">
|
||||
<IconStar size={14} />
|
||||
</span>{' '}
|
||||
<span className="tnum">{q.reputation.toFixed(1)}</span>
|
||||
<span className="tnum">{qc2.reputation.toFixed(1)}</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="kx-compare__metric-col">종합 점수</td>
|
||||
{quotes.map((q) => (
|
||||
<td key={q.alias} className={q.recommended ? 'kx-compare__score' : ''}>
|
||||
{q.recommended ? (
|
||||
<span className="kx-compare__score-val tnum">{q.score.toFixed(1)}</span>
|
||||
{quotes.map((qc2) => (
|
||||
<td key={qc2.bidId} className={qc2.recommended ? 'kx-compare__score' : ''}>
|
||||
{qc2.recommended ? (
|
||||
<span className="kx-compare__score-val tnum">
|
||||
{qc2.score.toFixed(1)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="kx-compare__val-strong tnum" style={{ color: 'var(--color-neutral-500)' }}>
|
||||
{q.score.toFixed(1)}
|
||||
<span
|
||||
className="kx-compare__val-strong tnum"
|
||||
style={{ color: 'var(--color-neutral-500)' }}
|
||||
>
|
||||
{qc2.score.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@ -153,7 +234,7 @@ export function AwardComparePage() {
|
||||
</div>
|
||||
<div className="kx-risk">
|
||||
<ul className="kx-barlist">
|
||||
{RISK_BARS.map((b) => (
|
||||
{(view?.riskBars ?? []).map((b) => (
|
||||
<li key={b.label}>
|
||||
<div className="kx-barlist__top">
|
||||
<span>{b.label}</span>
|
||||
@ -173,29 +254,20 @@ export function AwardComparePage() {
|
||||
<div className="kx-barlist__track">
|
||||
<span
|
||||
className={`kx-barlist__fill ${b.tone === 'success' ? 'kx-barlist__fill--success' : 'kx-barlist__fill--ai'}`}
|
||||
style={{ width: `${b.pct}%` }}
|
||||
style={{ width: `${Math.min(b.pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="kx-risk__grade">
|
||||
<span className="kx-award__rec-cell-label">AI 최종 등급</span>
|
||||
<span className="kx-risk__grade-value">A+</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-success)' }}>안정적 시공 보장</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="kx-card__hint" style={{ marginTop: 12 }}>
|
||||
업체 A는 최근 3년 KINTEX 내 유사 규모(100~150㎡) 시공 12회 수행, 클레임 발생률 0%를
|
||||
기록했습니다.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 우 — 낙찰 선정 패널 */}
|
||||
<aside className="kx-award__panel" aria-label="낙찰 선정">
|
||||
<h2>낙찰 선정</h2>
|
||||
<RecommendCard quote={recommended} />
|
||||
{selected && <RecommendCard quote={selected} />}
|
||||
|
||||
<div className="kx-field">
|
||||
<label className="kx-field__label" htmlFor="award-reason">
|
||||
@ -205,23 +277,24 @@ export function AwardComparePage() {
|
||||
id="award-reason"
|
||||
className="kx-award__textarea"
|
||||
placeholder="종합 평가 결과 및 업체 강점을 입력하세요. 예: 최저가 응찰 + 과거 동일 규모 행사 수행 실적 우수"
|
||||
value={reason}
|
||||
value={alreadyAwarded ? (view?.awarded?.awardedAt ? '낙찰 완료' : reason) : reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={awarded}
|
||||
disabled={alreadyAwarded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={awarded ? 'secondary' : 'primary'}
|
||||
variant={alreadyAwarded ? 'secondary' : 'primary'}
|
||||
block
|
||||
leadingIcon={<IconCheckCircle size={16} />}
|
||||
onClick={onAward}
|
||||
disabled={awarded}
|
||||
disabled={alreadyAwarded || awardM.isPending}
|
||||
>
|
||||
{awarded ? '낙찰 승인 완료' : '낙찰(Award) 최종 승인'}
|
||||
</Button>
|
||||
<Button variant="ghost" block onClick={() => show('재입찰 요청 — 백엔드 연동 예정')}>
|
||||
재입찰 요청
|
||||
{alreadyAwarded
|
||||
? '낙찰 승인 완료'
|
||||
: awardM.isPending
|
||||
? '승인 중…'
|
||||
: '낙찰(Award) 최종 승인'}
|
||||
</Button>
|
||||
|
||||
<div className="kx-award__warn">
|
||||
@ -254,17 +327,17 @@ export function AwardComparePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendCard({ quote }: { quote: QuoteColumn }) {
|
||||
function RecommendCard({ quote }: { quote: Quote }) {
|
||||
return (
|
||||
<div className="kx-award__rec">
|
||||
<div className="kx-award__rec-top">
|
||||
<span className="kx-award__rec-avatar">{quote.alias.replace(/[^A-Za-z]/g, '') || 'A'}</span>
|
||||
<span className="kx-award__rec-avatar">{quote.alias.slice(0, 2)}</span>
|
||||
<div>
|
||||
<p className="kx-award__rec-name">
|
||||
{quote.alias} ({quote.subtitle})
|
||||
</p>
|
||||
<span className="kx-award__rec-ai">
|
||||
<IconAiBot size={16} /> 신뢰도 98% 분석
|
||||
<IconAiBot size={16} /> 종합 점수 {quote.score.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* 옥션 화면 공용 소도구 — 데모 토스트(뮤테이션 로컬 처리 안내). */
|
||||
/* 옥션 화면 공용 소도구 — 토스트(성공/오류) + 오류 메시지 추출. */
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { IconCheckCircle } from '../../components/ui/icons';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
|
||||
export function useToast() {
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
@ -19,11 +20,21 @@ export function useToast() {
|
||||
return { show, node };
|
||||
}
|
||||
|
||||
/** 화면 상단 공용 "샘플 데이터" 배지. */
|
||||
export function SampleBadge() {
|
||||
return (
|
||||
<span className="kx-auc__sample" title="M15 역경매 엔진 미구현 — 시연용 정적 데이터">
|
||||
샘플 데이터
|
||||
</span>
|
||||
);
|
||||
/** 서버 오류 봉투 → 사용자 메시지. 봉인/등록업체/마감 코드는 도메인 문구로 치환. */
|
||||
export function errMessage(e: unknown): string {
|
||||
if (e instanceof ApiRequestError) {
|
||||
switch (e.code) {
|
||||
case 'NOT_REGISTERED_COMPANY':
|
||||
return '킨텍스 등록업체만 응찰할 수 있습니다.';
|
||||
case 'FORBIDDEN':
|
||||
return '이 작업에 대한 권한이 없습니다.';
|
||||
case 'CONFLICT':
|
||||
return '마감되었거나 현재 상태와 충돌하여 처리할 수 없습니다.';
|
||||
case 'VALIDATION':
|
||||
return e.message || '입력값을 확인해 주세요.';
|
||||
default:
|
||||
return e.message || '요청을 처리하지 못했습니다.';
|
||||
}
|
||||
}
|
||||
return '요청을 처리하지 못했습니다.';
|
||||
}
|
||||
|
||||
@ -255,12 +255,14 @@
|
||||
max-height: 720px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.kx-field {
|
||||
/* ★ 폼 유틸(kx-field*/kx-input/kx-grid-2)은 .kx-auc 로 스코프 격리(QA: 전역 재정의 방지).
|
||||
옥션/낙찰 화면 루트에 .kx-auc 를 부여하므로 타 화면의 동명 클래스에 영향을 주지 않는다. */
|
||||
.kx-auc .kx-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kx-field__label {
|
||||
.kx-auc .kx-field__label {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-700);
|
||||
font-weight: 600;
|
||||
@ -268,12 +270,12 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.kx-field__label-note {
|
||||
.kx-auc .kx-field__label-note {
|
||||
font-size: 11px;
|
||||
color: var(--color-error);
|
||||
font-weight: 500;
|
||||
}
|
||||
.kx-input {
|
||||
.kx-auc .kx-input {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-card);
|
||||
@ -283,11 +285,26 @@
|
||||
color: var(--color-neutral-900);
|
||||
width: 100%;
|
||||
}
|
||||
.kx-grid-2 {
|
||||
.kx-auc .kx-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
/* 비교표 컬럼 선택 버튼(낙찰 대상 선택) — 헤더 텍스트 스택을 버튼으로 감싼다. */
|
||||
.kx-compare__co-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.kx-compare__co-select[aria-pressed='true'] .kx-compare__co-name {
|
||||
color: var(--color-primary, var(--color-ai-accent));
|
||||
}
|
||||
|
||||
/* 가중치 슬라이더 카드 */
|
||||
.kx-weights {
|
||||
|
||||
@ -86,12 +86,20 @@ export interface BidResult {
|
||||
round: number;
|
||||
subtotal: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
total: number; // 경쟁 순위 금액(부가세 별도)
|
||||
grandTotal: number; // 부가세 포함 총액
|
||||
version: number;
|
||||
myRank: number | null;
|
||||
lowestPrice: number | null;
|
||||
}
|
||||
|
||||
export interface QuoteLine {
|
||||
trade: string;
|
||||
material: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
bidId: string;
|
||||
companyId: string;
|
||||
@ -181,10 +189,11 @@ export interface CreateAuctionBody {
|
||||
}
|
||||
|
||||
export interface BidBody {
|
||||
total: number;
|
||||
total?: number; // 라인아이템 미전송 시 경쟁 금액(부가세 별도)
|
||||
leadDays?: number;
|
||||
validUntil?: string | null;
|
||||
terms?: string | null;
|
||||
lines?: QuoteLine[];
|
||||
}
|
||||
|
||||
export interface AwardBody {
|
||||
@ -210,6 +219,7 @@ export const auctionApi = {
|
||||
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),
|
||||
close: (id: string) => api.post<AuctionSummary>(`/api/auctions/${encodeURIComponent(id)}/close`),
|
||||
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`),
|
||||
};
|
||||
|
||||
@ -41,6 +41,38 @@ export interface CmsTranslation {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CmsContentVersion {
|
||||
id: string;
|
||||
contentId: string;
|
||||
versionNo: number;
|
||||
title: string | null;
|
||||
body: string | null;
|
||||
status: FlowStatus;
|
||||
authorName: string | null;
|
||||
note: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface CmsMedia {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
fileName: string;
|
||||
url: string;
|
||||
contentType: string | null;
|
||||
sizeBytes: number | null;
|
||||
altText: string | null;
|
||||
uploaderName: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface ContentUpdateRequest {
|
||||
title: string;
|
||||
body?: string | null;
|
||||
scheduledAt?: string | null;
|
||||
signage?: boolean | null;
|
||||
mailing?: boolean | null;
|
||||
}
|
||||
|
||||
export interface MicrositeSection {
|
||||
id: string;
|
||||
type: string;
|
||||
@ -102,12 +134,37 @@ export const cmsApi = {
|
||||
return api.get<PageResponse<CmsContent>>(`/api/cms/contents?${qs.toString()}`);
|
||||
},
|
||||
createContent: (body: ContentCreateRequest) => api.post<CmsContent>('/api/cms/contents', body),
|
||||
getContent: (id: string) => api.get<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}`),
|
||||
updateContent: (id: string, body: ContentUpdateRequest) =>
|
||||
api.put<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}`, body),
|
||||
transition: (id: string, value: FlowStatus) =>
|
||||
api.patch<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}/status?value=${value}`),
|
||||
getVersions: (id: string) =>
|
||||
api.get<CmsContentVersion[]>(`/api/cms/contents/${encodeURIComponent(id)}/versions`),
|
||||
rollback: (id: string, versionNo: number) =>
|
||||
api.post<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}/rollback?versionNo=${versionNo}`),
|
||||
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),
|
||||
/** AI 자동 번역(초벌) — 백엔드 미배선 시 501. 프론트는 degraded("준비 중")로 처리. */
|
||||
aiTranslate: (id: string, lang: Lang) =>
|
||||
api.post<CmsTranslation>(
|
||||
`/api/cms/contents/${encodeURIComponent(id)}/translations/ai?lang=${lang}`,
|
||||
),
|
||||
// ── 미디어 라이브러리 ──
|
||||
listMedia: (limit = 60) => api.get<CmsMedia[]>(`/api/cms/media?limit=${limit}`),
|
||||
uploadMedia: (file: File, altText?: string) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (altText) form.append('altText', altText);
|
||||
return api.postForm<CmsMedia>('/api/cms/media', form);
|
||||
},
|
||||
// ── 공개 조회(무인증·게시 상태만) ──
|
||||
publicByType: (type: string, limit = 50) =>
|
||||
api.get<CmsContent[]>(`/api/public/cms/${encodeURIComponent(type)}?limit=${limit}`, {
|
||||
anonymous: true,
|
||||
}),
|
||||
getMicrosite: (exhibitorId: string) =>
|
||||
api.get<Microsite>(`/api/exhibitors/${encodeURIComponent(exhibitorId)}/microsite`),
|
||||
saveMicrosite: (exhibitorId: string, body: MicrositeSaveRequest) =>
|
||||
|
||||
@ -1,125 +1,65 @@
|
||||
/*
|
||||
* SCR-38 업체 수주 부스 대시보드(업체 포털 홈). Stitch scr_38_contractor_booth_dashboard 이식.
|
||||
* 대상: 장치·공사업체. 상단 KPI + 좌 수주 부스 리스트(시공 스테퍼) + 우 옥션 참여·알림 피드.
|
||||
* ★ M3·M15 부분 미구현 → 정적 샘플 데이터("샘플 데이터" 배지). 실 API fetch 없음.
|
||||
* 버튼 액션은 로컬 데모("백엔드 연동 예정").
|
||||
* 보안: 옥션 참여 현황의 입찰가는 자사 응찰 금액만 표기(타사 금액 비노출).
|
||||
* SCR-38 업체 수주 부스 대시보드(업체 포털 홈) [M15]. 실 API 전환.
|
||||
* 정본: GET /api/contractor/dashboard (ContractorDashboard).
|
||||
* 대상: 장치·공사업체. 상단 KPI + 좌 수주 부스 리스트 + 우 옥션 참여·알림 피드.
|
||||
* 보안: 옥션 참여 위젯은 자사 응찰 금액만 표기(타사 금액 비노출 — 서버가 자사 데이터만 반환).
|
||||
*/
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 {
|
||||
IconCheckCircle,
|
||||
IconDocument,
|
||||
IconGrid,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
IconWarning,
|
||||
} from '../../components/ui/icons';
|
||||
import { SampleBadge, useToast } from '../auction/aucShared';
|
||||
import { formatWon } from '../auction/sampleAuction';
|
||||
import { useToast } from '../auction/aucShared';
|
||||
import {
|
||||
contractorApi,
|
||||
formatWon,
|
||||
type AwardedBooth,
|
||||
type MyBid,
|
||||
} from '../auction/auctionApi';
|
||||
import './contractor.css';
|
||||
|
||||
interface Kpi {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: 'normal' | 'success' | 'error';
|
||||
}
|
||||
const KPIS: Kpi[] = [
|
||||
{ label: '수주 부스', value: 8 },
|
||||
{ label: '진행 옥션', value: 3 },
|
||||
{ label: '응찰 대기', value: 2 },
|
||||
{ label: '시공 진행', value: 5, tone: 'success' },
|
||||
{ label: '마감 임박', value: 1, tone: 'error' },
|
||||
];
|
||||
|
||||
const STEPS = ['설계', '규정검증', '반입', '시공', '검수'];
|
||||
|
||||
interface AwardedBooth {
|
||||
event: string;
|
||||
eventTag: string;
|
||||
name: string;
|
||||
booth: string;
|
||||
client: string;
|
||||
dday: number | null;
|
||||
stepIndex: number; // 현재 단계(0-based)
|
||||
variant: 'primary' | 'sub';
|
||||
progressPct?: number;
|
||||
statusText?: string;
|
||||
}
|
||||
const BOOTHS: AwardedBooth[] = [
|
||||
{
|
||||
event: 'Smart Factory 2026',
|
||||
eventTag: 'Smart Factory 2026',
|
||||
name: '스마트팩토리 코리아 2026',
|
||||
booth: 'A-102',
|
||||
client: '(주)한빛로보틱스',
|
||||
dday: 14,
|
||||
stepIndex: 1,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
event: 'K-Medical Expo 2026',
|
||||
eventTag: 'K-Medical Expo 2026',
|
||||
name: 'K-메디컬 엑스포',
|
||||
booth: 'D-405',
|
||||
client: '세종메디텍',
|
||||
dday: null,
|
||||
stepIndex: 0,
|
||||
variant: 'sub',
|
||||
progressPct: 15,
|
||||
statusText: '시공 예정',
|
||||
},
|
||||
];
|
||||
|
||||
interface BidRow {
|
||||
name: string;
|
||||
price: number;
|
||||
rank: number;
|
||||
dday: number;
|
||||
dim?: boolean;
|
||||
}
|
||||
const BIDS: BidRow[] = [
|
||||
{ name: '전기공사 · Hall 1', price: 24_500_000, rank: 2, dday: 2 },
|
||||
{ name: '네트워크 가설 · Hall 3', price: 12_000_000, rank: 1, dday: 5 },
|
||||
{ name: '조명 렌탈 · 전시장 전체', price: 45_000_000, rank: 5, dday: 1, dim: true },
|
||||
];
|
||||
|
||||
interface FeedItem {
|
||||
text: string;
|
||||
meta: string;
|
||||
tone: 'ok' | 'warn' | 'info';
|
||||
}
|
||||
const FEED: FeedItem[] = [
|
||||
{ text: 'Booth A-102 규정 검증 완료', meta: '방금 전 · KINTEX 운영국', tone: 'ok' },
|
||||
{ text: '안전 교육 갱신 필요 알림', meta: '2시간 전 · 시스템', tone: 'warn' },
|
||||
{ text: '신규 옥션 공고: 제2전시장 외벽 래핑', meta: '어제 · 입찰관리부', tone: 'info' },
|
||||
];
|
||||
|
||||
export function ContractorBoothDashboardPage() {
|
||||
const { show, node: toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { node: toast } = useToast();
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['contractor-dashboard'],
|
||||
queryFn: () => contractorApi.dashboard(),
|
||||
retry: false,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const data = q.data;
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-cbd__head">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="kx-cbd__title">수주 부스</h1>
|
||||
<SampleBadge />
|
||||
<p className="kx-cbd__subtitle">{data?.companyName ?? '업체 포털'}</p>
|
||||
</div>
|
||||
<p className="kx-cbd__subtitle">(주)글로벌시공 파트너 · 업체 포털</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => show('옥션 참여 목록 — 백엔드 연동 예정')}>
|
||||
<Button variant="secondary" onClick={() => navigate('/auctions')}>
|
||||
옥션 참여하기
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{q.isLoading && <Skeleton height={96} radius={12} />}
|
||||
{q.isError && !q.isLoading && (
|
||||
<ErrorState message="대시보드를 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||
)}
|
||||
|
||||
{!q.isLoading && !q.isError && data && (
|
||||
<>
|
||||
{/* KPI 밴드 */}
|
||||
<section className="kx-cbd__kpis" aria-label="핵심 지표">
|
||||
{KPIS.map((k) => (
|
||||
<div
|
||||
key={k.label}
|
||||
className={`kx-kpi ${k.tone === 'error' ? 'kx-kpi--error' : ''}`}
|
||||
>
|
||||
{data.kpis.map((k) => (
|
||||
<div key={k.label} className={`kx-kpi ${k.tone === 'error' ? 'kx-kpi--error' : ''}`}>
|
||||
<span className="kx-kpi__label">
|
||||
{k.tone === 'error' && <IconWarning size={14} />}
|
||||
{k.label}
|
||||
@ -139,14 +79,22 @@ export function ContractorBoothDashboardPage() {
|
||||
<section className="kx-cbd__col" aria-label="수주 부스 리스트">
|
||||
<div className="kx-card__head" style={{ marginBottom: 0 }}>
|
||||
<h2 style={{ fontSize: 'var(--fs-h2)' }}>수주 부스 리스트</h2>
|
||||
<Button variant="ghost" onClick={() => show('전체 수주 부스 — 백엔드 연동 예정')}>
|
||||
전체보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{BOOTHS.map((b) => (
|
||||
<BoothCard key={b.booth} booth={b} onAction={(label) => show(`${label} — 백엔드 연동 예정`)} />
|
||||
))}
|
||||
{data.awardedBooths.length === 0 ? (
|
||||
<EmptyState
|
||||
title="수주한 부스가 없습니다"
|
||||
description="진행 중인 옥션에 응찰해 시공을 수주하세요."
|
||||
/>
|
||||
) : (
|
||||
data.awardedBooths.map((b) => (
|
||||
<BoothCard
|
||||
key={b.auctionId}
|
||||
booth={b}
|
||||
onOpen={() => navigate(`/auctions/${b.auctionId}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 우 — 위젯 */}
|
||||
@ -156,14 +104,24 @@ export function ContractorBoothDashboardPage() {
|
||||
<h2 style={{ fontSize: 'var(--fs-h3)' }}>옥션 참여 현황</h2>
|
||||
</div>
|
||||
<div className="kx-cbd-widget__rows">
|
||||
{BIDS.map((bid) => (
|
||||
<BidItem key={bid.name} bid={bid} />
|
||||
))}
|
||||
{data.myAuctionBids.length === 0 ? (
|
||||
<p className="kx-cbd-bid__price" style={{ padding: '8px 0' }}>
|
||||
참여 중인 옥션이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
data.myAuctionBids.map((bid) => (
|
||||
<BidItem
|
||||
key={bid.auctionId}
|
||||
bid={bid}
|
||||
onOpen={() => navigate(`/auctions/${bid.auctionId}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
block
|
||||
onClick={() => show('옥션 대시보드 — 백엔드 연동 예정')}
|
||||
onClick={() => navigate('/auctions')}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
옥션 대시보드로 이동
|
||||
@ -175,7 +133,10 @@ export function ContractorBoothDashboardPage() {
|
||||
<h2 style={{ fontSize: 'var(--fs-h3)' }}>알림 피드</h2>
|
||||
</div>
|
||||
<div className="kx-feed">
|
||||
{FEED.map((f) => (
|
||||
{data.feed.length === 0 ? (
|
||||
<p className="kx-feed__meta">새로운 알림이 없습니다.</p>
|
||||
) : (
|
||||
data.feed.map((f) => (
|
||||
<div key={f.text} className="kx-feed__item">
|
||||
<span className={`kx-feed__dot kx-feed__dot--${f.tone}`}>
|
||||
{f.tone === 'ok' ? (
|
||||
@ -189,98 +150,69 @@ export function ContractorBoothDashboardPage() {
|
||||
<p className="kx-feed__text">{f.text}</p>
|
||||
<p className="kx-feed__meta">{f.meta}</p>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoothCard({ booth, onAction }: { booth: AwardedBooth; onAction: (label: string) => void }) {
|
||||
function BoothCard({ booth, onOpen }: { booth: AwardedBooth; onOpen: () => void }) {
|
||||
return (
|
||||
<article className={`kx-booth ${booth.variant === 'sub' ? 'kx-booth--sub' : ''}`}>
|
||||
<article className="kx-booth">
|
||||
<div className="kx-booth__top">
|
||||
<div>
|
||||
<span className="kx-booth__event-tag">{booth.eventTag}</span>
|
||||
<span className="kx-booth__event-tag">{booth.event}</span>
|
||||
<h4 className="kx-booth__name">{booth.name}</h4>
|
||||
<div className="kx-booth__meta">
|
||||
<span>
|
||||
<IconGrid size={14} /> Booth {booth.booth}
|
||||
<IconGrid size={14} /> Booth {booth.booth || '—'}
|
||||
</span>
|
||||
{booth.client && (
|
||||
<span>
|
||||
<IconUsers size={14} /> {booth.client}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kx-booth__right">
|
||||
{booth.dday != null ? (
|
||||
<>
|
||||
<p className="kx-booth__right-label">남은 기간</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<DdayChip dday={booth.dday} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="kx-booth__right-label">상태</p>
|
||||
<p className="kx-booth__right-value" style={{ color: 'var(--color-success)', fontSize: 'var(--fs-body)' }}>
|
||||
{booth.statusText}
|
||||
<p
|
||||
className="kx-booth__right-value"
|
||||
style={{ color: 'var(--color-success)', fontSize: 'var(--fs-body)' }}
|
||||
>
|
||||
{booth.stage}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booth.variant === 'primary' ? (
|
||||
<>
|
||||
<div className="kx-stepper" role="list" aria-label="시공 단계">
|
||||
{STEPS.map((label, i) => {
|
||||
const state =
|
||||
i < booth.stepIndex ? 'done' : i === booth.stepIndex ? 'current' : 'todo';
|
||||
return (
|
||||
<div key={label} role="listitem" className={`kx-step kx-step--${state}`}>
|
||||
<span className="kx-step__dot">{i + 1}</span>
|
||||
<span className="kx-step__label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="kx-booth__actions">
|
||||
<Button variant="primary" leadingIcon={<IconSettings size={16} />} onClick={() => onAction('설계 스튜디오')}>
|
||||
설계 스튜디오
|
||||
</Button>
|
||||
<Button variant="secondary" leadingIcon={<IconDocument size={16} />} onClick={() => onAction('규정 리포트')}>
|
||||
규정 리포트
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onAction('반입 예약')}>
|
||||
반입 예약
|
||||
<Button variant="secondary" leadingIcon={<IconDocument size={16} />} onClick={onOpen}>
|
||||
옥션 상세
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="kx-booth__progress" aria-label={`진행률 ${booth.progressPct}%`}>
|
||||
<span style={{ width: `${booth.progressPct ?? 0}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function BidItem({ bid }: { bid: BidRow }) {
|
||||
const rankCls = bid.rank === 1 ? 'kx-rankpill--1' : bid.rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low';
|
||||
function BidItem({ bid, onOpen }: { bid: MyBid; onOpen: () => void }) {
|
||||
const rank = bid.myRank ?? 0;
|
||||
const rankCls = rank === 1 ? 'kx-rankpill--1' : rank > 0 && rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low';
|
||||
return (
|
||||
<div className={`kx-cbd-bid ${bid.dim ? 'kx-cbd-bid--dim' : ''}`}>
|
||||
<button type="button" className="kx-cbd-bid" onClick={onOpen} style={{ width: '100%', textAlign: 'left', border: 0, background: 'transparent' }}>
|
||||
<div>
|
||||
<p className="kx-cbd-bid__name">{bid.name}</p>
|
||||
<p className="kx-cbd-bid__price tnum">내 입찰가: {formatWon(bid.price)}</p>
|
||||
<p className="kx-cbd-bid__name">{bid.auctionTitle}</p>
|
||||
<p className="kx-cbd-bid__price tnum">내 입찰가: {formatWon(bid.myPrice)}</p>
|
||||
</div>
|
||||
<div className="kx-cbd-bid__right">
|
||||
<span className={`kx-rankpill ${rankCls}`}>{bid.rank}위</span>
|
||||
<span className={`kx-rankpill ${rankCls}`}>{rank > 0 ? `${rank}위` : '-'}</span>
|
||||
<DdayChip dday={bid.dday} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
395
src/frontend/src/screens/visitor/CheckinDeskPage.tsx
Normal file
395
src/frontend/src/screens/visitor/CheckinDeskPage.tsx
Normal file
@ -0,0 +1,395 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconSearch, IconWifiOff, IconCheckCircle, IconGrid, IconUsers } from '../../components/ui/icons';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import { CHART } from '../chartColors';
|
||||
import { REG_TYPE_LABEL, type RegVisitorType } from './sampleVisitor';
|
||||
import {
|
||||
visitorApi,
|
||||
type CheckinResultDto,
|
||||
type CheckinSearchItemDto,
|
||||
type CheckinStatsDto,
|
||||
} from './visitorApi';
|
||||
import './visitor.css';
|
||||
import './checkin.css';
|
||||
|
||||
/*
|
||||
* SCR-31 현장 체크인 데스크 / 키오스크 (M10). Stitch scr_31_checkin_desk 이식.
|
||||
* 정상 경로: POST /api/events/{eventId}/checkin (토큰/등록번호), GET /checkin/search, GET /checkin/stats,
|
||||
* POST /visitors/{id}/badge/reissue. 3상태(로딩/빈/에러) 준수.
|
||||
* ★ 보안 불변: 관람객 이름·연락처는 서버 마스킹 필드만 소비(원문 미노출, N2/R10).
|
||||
* ★ 카메라 대신 토큰 입력 + 수동 검색으로 실동작(요청 사양). 오프라인 폴백 배지 표기(F049).
|
||||
*/
|
||||
export function CheckinDeskPage() {
|
||||
const eventId = useResolvedEventId();
|
||||
const qc = useQueryClient();
|
||||
const [kiosk, setKiosk] = useState(false);
|
||||
const [token, setToken] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<CheckinSearchItemDto[] | null>(null);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [card, setCard] = useState<CheckinResultDto | null>(null);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
|
||||
const statsQ = useQuery({
|
||||
queryKey: ['checkin-stats', eventId],
|
||||
queryFn: () => visitorApi.checkinStats(eventId as string),
|
||||
enabled: !!eventId,
|
||||
retry: false,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const degraded = isDegradable(statsQ.error);
|
||||
const stats: CheckinStatsDto | null = statsQ.data ?? (degraded ? SAMPLE_STATS : null);
|
||||
const hardError = statsQ.isError && !degraded;
|
||||
|
||||
const checkinMut = useMutation({
|
||||
mutationFn: (body: { token?: string; registrationId?: string }) =>
|
||||
visitorApi.checkin(eventId as string, body),
|
||||
onSuccess: (res) => {
|
||||
setCard(res);
|
||||
setScanError(null);
|
||||
setToken('');
|
||||
setSearchResults(null);
|
||||
setSearch('');
|
||||
qc.invalidateQueries({ queryKey: ['checkin-stats', eventId] });
|
||||
},
|
||||
onError: (e) => {
|
||||
setCard(null);
|
||||
setScanError(
|
||||
e instanceof ApiRequestError && e.code === 'NOT_FOUND'
|
||||
? '등록 정보를 찾을 수 없습니다 — 현장 등록이 필요합니다.'
|
||||
: '체크인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const reissueMut = useMutation({
|
||||
mutationFn: (registrationId: string) => visitorApi.reissueBadge(eventId as string, registrationId),
|
||||
onSuccess: (res) =>
|
||||
setCard((c) => (c ? { ...c, badgeCode: res.badgeCode, badgeIssued: true } : c)),
|
||||
});
|
||||
|
||||
const runSearch = async () => {
|
||||
if (!eventId || search.trim().length < 2) {
|
||||
setSearchError('검색어는 2자 이상 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
setSearchError(null);
|
||||
try {
|
||||
const rows = await visitorApi.checkinSearch(eventId, search.trim());
|
||||
setSearchResults(rows);
|
||||
} catch (e) {
|
||||
setSearchResults([]);
|
||||
setSearchError(
|
||||
e instanceof ApiRequestError && e.code === 'NETWORK'
|
||||
? '네트워크 연결을 확인해 주세요.'
|
||||
: '검색 중 오류가 발생했습니다.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const submitToken = () => {
|
||||
if (!token.trim()) {
|
||||
setScanError('QR 토큰(배지 코드)을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
checkinMut.mutate({ token: token.trim() });
|
||||
};
|
||||
|
||||
const chartData = useMemo(
|
||||
() => (stats?.byHour ?? []).map((b) => ({ hour: b.hour, count: b.count })),
|
||||
[stats],
|
||||
);
|
||||
|
||||
if (!eventId) {
|
||||
return (
|
||||
<div className="kx-vis">
|
||||
<EmptyState title="행사를 선택해 주세요" description="체크인을 진행할 행사가 지정되지 않았습니다." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`kx-vis kx-checkin ${kiosk ? 'is-kiosk' : ''}`}>
|
||||
<header className="kx-vis__head">
|
||||
<div>
|
||||
<h1 className="kx-vis__title">현장 체크인 데스크</h1>
|
||||
<p className="kx-vis__subtitle">배지 QR 스캔 · 수동 검색 · 실시간 입장 집계</p>
|
||||
</div>
|
||||
<div className="kx-vis__head-actions">
|
||||
{degraded && (
|
||||
<span className="kx-vis__degraded" title="집계 API 미연결 — 시연 데이터">
|
||||
<IconWifiOff size={14} /> 오프라인 — 로컬 저장 후 동기화
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`kx-checkin__kiosk-toggle ${kiosk ? 'is-on' : ''}`}
|
||||
aria-pressed={kiosk}
|
||||
onClick={() => setKiosk((v) => !v)}
|
||||
>
|
||||
<IconGrid size={16} /> 키오스크 모드
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-checkin__grid">
|
||||
{/* 좌: 스캔 존 + 수동 검색 */}
|
||||
<section className="kx-card kx-checkin__scan" aria-label="배지 스캔">
|
||||
<div className="kx-checkin__frame" aria-hidden="true">
|
||||
<span className="kx-checkin__frame-corner tl" />
|
||||
<span className="kx-checkin__frame-corner tr" />
|
||||
<span className="kx-checkin__frame-corner bl" />
|
||||
<span className="kx-checkin__frame-corner br" />
|
||||
<ScanGlyph />
|
||||
<p className="kx-checkin__frame-hint">배지 QR을 스캔하세요</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="kx-checkin__token"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitToken();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="kx-token" className="kx-checkin__label">QR 토큰 / 배지 코드 입력</label>
|
||||
<div className="kx-checkin__token-row">
|
||||
<input
|
||||
id="kx-token"
|
||||
className="kx-checkin__input"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="예: QR-XXXX 또는 KTX-V-0001"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button type="submit" disabled={checkinMut.isPending}>
|
||||
{checkinMut.isPending ? '확인 중…' : '체크인'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="kx-checkin__divider"><span>또는 수동 검색</span></div>
|
||||
|
||||
<div className="kx-checkin__search">
|
||||
<label htmlFor="kx-search" className="kx-checkin__label">이름 · 연락처 · 등록번호</label>
|
||||
<div className="kx-checkin__token-row">
|
||||
<input
|
||||
id="kx-search"
|
||||
className="kx-checkin__input"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="검색어 2자 이상"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button variant="secondary" leadingIcon={<IconSearch size={16} />} onClick={runSearch}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
{searchError && <p className="kx-checkin__err" role="alert">{searchError}</p>}
|
||||
|
||||
{searchResults && searchResults.length > 0 && (
|
||||
<ul className="kx-checkin__results" aria-label="검색 결과">
|
||||
{searchResults.map((r) => (
|
||||
<li key={r.registrationId} className="kx-checkin__result">
|
||||
<span className="kx-vis__avatar" aria-hidden="true">{r.nameMasked.charAt(0)}</span>
|
||||
<div className="kx-checkin__result-info">
|
||||
<span className="kx-checkin__result-name">
|
||||
{r.nameMasked} <TypePill type={r.type} />
|
||||
</span>
|
||||
<span className="kx-checkin__result-meta">
|
||||
{r.company ?? '-'} · {r.phoneMasked ?? '-'} · {r.badgeCode ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
{r.checkinState === 'done' ? (
|
||||
<span className="kx-checkin__done-tag"><IconCheckCircle size={14} /> 완료</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => checkinMut.mutate({ registrationId: r.registrationId })}
|
||||
disabled={checkinMut.isPending}
|
||||
>
|
||||
체크인
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{searchResults && searchResults.length === 0 && !searchError && (
|
||||
<p className="kx-checkin__empty">검색 결과가 없습니다 — 현장 등록을 진행해 주세요.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 중: 스캔 결과 카드 */}
|
||||
<section className="kx-card kx-checkin__result-card" aria-label="체크인 결과">
|
||||
{scanError && (
|
||||
<div className="kx-checkin__scan-error" role="alert">
|
||||
<p>{scanError}</p>
|
||||
<Button variant="secondary" onClick={() => setScanError(null)}>닫기</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!card && !scanError && (
|
||||
<div className="kx-checkin__idle">
|
||||
<span className="kx-checkin__pulse" aria-hidden="true" />
|
||||
<p>배지를 스캔하거나 검색해 체크인을 시작하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card && (
|
||||
<div className={`kx-checkin__visitor ${card.alreadyCheckedIn ? 'is-dup' : 'is-ok'}`}>
|
||||
<div className="kx-checkin__visitor-status">
|
||||
{card.alreadyCheckedIn ? (
|
||||
<span className="kx-checkin__badge-dup">이미 체크인됨</span>
|
||||
) : (
|
||||
<span className="kx-checkin__badge-ok"><IconCheckCircle size={18} /> 체크인 완료</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="kx-vis__avatar kx-vis__avatar--lg" aria-hidden="true">
|
||||
{card.nameMasked.charAt(0)}
|
||||
</span>
|
||||
<h2 className="kx-checkin__visitor-name">{card.nameMasked}</h2>
|
||||
<p className="kx-checkin__visitor-meta">
|
||||
<TypePill type={card.type} /> · {card.company ?? '-'}
|
||||
</p>
|
||||
<dl className="kx-checkin__visitor-dl">
|
||||
<div><dt>배지 번호</dt><dd className="tnum">{card.badgeCode ?? '-'}</dd></div>
|
||||
<div><dt>체크인 시각</dt><dd className="tnum">{card.checkinAt ?? '-'}</dd></div>
|
||||
</dl>
|
||||
<div className="kx-checkin__visitor-actions">
|
||||
<Button block leadingIcon={<QrGlyph size={16} />}>즉석 배지 인쇄</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
block
|
||||
onClick={() => reissueMut.mutate(card.registrationId)}
|
||||
disabled={reissueMut.isPending}
|
||||
>
|
||||
{reissueMut.isPending ? '재발급 중…' : '배지 재발급'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="kx-vis__pii-note">개인정보 보호 · 이름 마스킹 표기(원문 미표시)</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 우: 실시간 입장 집계 */}
|
||||
<aside className="kx-checkin__side" aria-label="실시간 입장">
|
||||
{statsQ.isLoading && !degraded && (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<Skeleton height={120} radius={12} />
|
||||
<Skeleton height={220} radius={12} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hardError && (
|
||||
<ErrorState message="입장 집계를 불러오지 못했습니다." onRetry={() => statsQ.refetch()} />
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
<div className="kx-card kx-checkin__inside">
|
||||
<span className="kx-checkin__inside-label"><IconUsers size={16} /> 현재 장내 추정</span>
|
||||
<strong className="kx-checkin__inside-num tnum">{stats.inside.toLocaleString()}명</strong>
|
||||
<div className="kx-checkin__inside-sub">
|
||||
<span>사전등록 <b className="tnum">{stats.preRegistered.toLocaleString()}</b></span>
|
||||
<span>체크인 <b className="tnum">{stats.checkedIn.toLocaleString()}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kx-card kx-checkin__hours">
|
||||
<div className="kx-card__head"><h2>시간대별 체크인</h2></div>
|
||||
{chartData.length === 0 ? (
|
||||
<EmptyState title="집계 없음" description="체크인이 시작되면 시간대별 추이가 표시됩니다." />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 11, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} allowDecimals={false} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => [`${(v as number).toLocaleString()}명`, '체크인']} />
|
||||
<Bar dataKey="count" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={28} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #E4E7EC',
|
||||
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
|
||||
} as const;
|
||||
|
||||
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 SAMPLE_STATS: CheckinStatsDto = {
|
||||
preRegistered: 12480,
|
||||
checkedIn: 8210,
|
||||
inside: 7553,
|
||||
byHour: [
|
||||
{ hour: '09시', count: 1240 },
|
||||
{ hour: '10시', count: 2180 },
|
||||
{ hour: '11시', count: 1760 },
|
||||
{ hour: '12시', count: 980 },
|
||||
{ hour: '13시', count: 1150 },
|
||||
{ hour: '14시', count: 900 },
|
||||
],
|
||||
};
|
||||
|
||||
function isDegradable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
|
||||
);
|
||||
}
|
||||
|
||||
/** 스캔 프레임 글리프(선 SVG). */
|
||||
function ScanGlyph() {
|
||||
return (
|
||||
<svg width={72} height={72} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M4 12h16" className="kx-checkin__scanline" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** QR 글리프(선 SVG, 장식). */
|
||||
function QrGlyph({ size = 40 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 3h7v7H3V3zm2 2v3h3V5H5zm-2 9h7v7H3v-7zm2 2v3h3v-3H5zM14 3h7v7h-7V3zm2 2v3h3V5h-3zM14 14h3v3h-3v-3zm4 0h3v3h-3v-3zm-4 4h3v3h-3v-3zm4 0h3v3h-3v-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,21 @@ export function LeadScoringPage() {
|
||||
const { boothId } = useParams();
|
||||
const [hotOnly, setHotOnly] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!eventId || exporting) return;
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
await visitorApi.exportLeadsCsv(eventId, boothId ?? null);
|
||||
} catch {
|
||||
setExportError('CSV 내보내기에 실패했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['leads', eventId, boothId ?? null],
|
||||
@ -60,8 +75,16 @@ export function LeadScoringPage() {
|
||||
{degraded && (
|
||||
<span className="kx-vis__degraded" title="리드 API 미연결 — 시연 데이터">오프라인 — 샘플</span>
|
||||
)}
|
||||
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>
|
||||
CSV 내보내기
|
||||
{exportError && (
|
||||
<span className="kx-vis__degraded" role="alert">{exportError}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
leadingIcon={<IconDownload size={16} />}
|
||||
onClick={handleExport}
|
||||
disabled={exporting || degraded}
|
||||
>
|
||||
{exporting ? '내보내는 중…' : 'CSV 내보내기'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
@ -169,7 +192,7 @@ export function LeadScoringPage() {
|
||||
</section>
|
||||
|
||||
{/* 리드 상세·팔로업 */}
|
||||
{selected && <LeadDetail lead={selected} />}
|
||||
{selected && <LeadDetail lead={selected} onExport={handleExport} exporting={exporting} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -177,7 +200,7 @@ export function LeadScoringPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function LeadDetail({ lead }: { lead: LeadItemDto }) {
|
||||
function LeadDetail({ lead, onExport, exporting }: { lead: LeadItemDto; onExport: () => void; exporting: boolean }) {
|
||||
const hot = lead.score >= 80;
|
||||
return (
|
||||
<aside className="kx-vis__lead-detail" aria-label="리드 상세">
|
||||
@ -229,7 +252,9 @@ function LeadDetail({ lead }: { lead: LeadItemDto }) {
|
||||
<div className="kx-vis__lead-actions">
|
||||
<Button block>EDM 발송하기</Button>
|
||||
<div className="kx-vis__lead-actions-row">
|
||||
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>CSV 내보내기</Button>
|
||||
<Button variant="secondary" leadingIcon={<IconDownload size={16} />} onClick={onExport} disabled={exporting}>
|
||||
{exporting ? '내보내는 중…' : 'CSV 내보내기'}
|
||||
</Button>
|
||||
<Button variant="secondary">노트 기록</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,9 +9,11 @@
|
||||
* GET /api/events/{eventId}/leads?boothId&page&size → ApiResponse<PageResponse<LeadItem>>
|
||||
* POST /api/public/events/{eventId}/register (공개 — 공개 P4 팀 배선)
|
||||
*/
|
||||
import { api } from '../../api/client';
|
||||
import { api, ApiRequestError, getAccessToken } from '../../api/client';
|
||||
import type { PageResponse } from '../../api/types';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
|
||||
|
||||
// ── SCR-30 요약 ──
|
||||
export interface VisitorKpiDto {
|
||||
label: string;
|
||||
@ -77,20 +79,95 @@ export interface LeadItemDto {
|
||||
aiGenerated: boolean;
|
||||
}
|
||||
|
||||
// ── SCR-31 현장 체크인 ──
|
||||
export interface CheckinResultDto {
|
||||
registrationId: string;
|
||||
nameMasked: string;
|
||||
type: string; // visitor|buyer|vip
|
||||
company: string | null;
|
||||
badgeCode: string | null;
|
||||
badgeIssued: boolean;
|
||||
checkinState: string; // done|waiting|cancelled
|
||||
alreadyCheckedIn: boolean;
|
||||
checkinAt: string | null;
|
||||
}
|
||||
export interface CheckinSearchItemDto {
|
||||
registrationId: string;
|
||||
nameMasked: string;
|
||||
type: string;
|
||||
company: string | null;
|
||||
phoneMasked: string | null;
|
||||
badgeCode: string | null;
|
||||
checkinState: string;
|
||||
}
|
||||
export interface HourBucketDto {
|
||||
hour: string;
|
||||
count: number;
|
||||
}
|
||||
export interface CheckinStatsDto {
|
||||
preRegistered: number;
|
||||
checkedIn: number;
|
||||
inside: number;
|
||||
byHour: HourBucketDto[];
|
||||
}
|
||||
export interface BadgeReissueResultDto {
|
||||
registrationId: string;
|
||||
badgeCode: string;
|
||||
badgeIssued: boolean;
|
||||
}
|
||||
|
||||
function evPath(eventId: string): string {
|
||||
return `/api/events/${encodeURIComponent(eventId)}`;
|
||||
}
|
||||
|
||||
export const visitorApi = {
|
||||
summary: (eventId: string) =>
|
||||
api.get<VisitorSummaryDto>(`/api/events/${encodeURIComponent(eventId)}/visitors/summary`),
|
||||
api.get<VisitorSummaryDto>(`${evPath(eventId)}/visitors/summary`),
|
||||
visitors: (eventId: string, page = 0, size = 20) =>
|
||||
api.get<PageResponse<VisitorListItemDto>>(
|
||||
`/api/events/${encodeURIComponent(eventId)}/visitors?page=${page}&size=${size}`,
|
||||
),
|
||||
api.get<PageResponse<VisitorListItemDto>>(`${evPath(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()}`,
|
||||
);
|
||||
return api.get<PageResponse<LeadItemDto>>(`${evPath(eventId)}/leads?${qs.toString()}`);
|
||||
},
|
||||
|
||||
// SCR-31 체크인 데스크 — 토큰/등록번호 체크인, 수동 검색, 실시간 집계, 배지 재발급.
|
||||
checkin: (eventId: string, body: { token?: string; registrationId?: string }) =>
|
||||
api.post<CheckinResultDto>(`${evPath(eventId)}/checkin`, body),
|
||||
checkinSearch: (eventId: string, q: string) =>
|
||||
api.get<CheckinSearchItemDto[]>(`${evPath(eventId)}/checkin/search?q=${encodeURIComponent(q)}`),
|
||||
checkinStats: (eventId: string) =>
|
||||
api.get<CheckinStatsDto>(`${evPath(eventId)}/checkin/stats`),
|
||||
reissueBadge: (eventId: string, registrationId: string) =>
|
||||
api.post<BadgeReissueResultDto>(
|
||||
`${evPath(eventId)}/visitors/${encodeURIComponent(registrationId)}/badge/reissue`,
|
||||
),
|
||||
|
||||
// SCR-32 리드 CSV 내보내기 — 봉투가 아닌 파일(byte) 응답이라 fetch 직결 후 blob 다운로드.
|
||||
exportLeadsCsv: async (eventId: string, boothId?: string | null): Promise<void> => {
|
||||
const qs = boothId ? `?boothId=${encodeURIComponent(boothId)}` : '';
|
||||
const token = getAccessToken();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${evPath(eventId)}/leads/export${qs}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiRequestError('NETWORK', '네트워크 연결을 확인해 주세요.', 0);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiRequestError('UNKNOWN', `CSV 내보내기에 실패했습니다. (${res.status})`, res.status);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `leads-${eventId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user