diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index 42224b3..53d5438 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -99,6 +99,7 @@ | 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 | **8·9차 웨이브(100대 기능 8종) + ★SCR-HOME 메인 홈 + 테넌트 표준 + 크롤 적재** — ①**SCR-HOME**(소유자 지시: 홈 없음 지적→design.md v2.1.2 확정판·Stitch 시안(프로젝트 정리 후 생성 성공, scr_home/) 정합): `/home` 랜딩(로그인→홈 전환·워크스페이스 선택 흡수)=역할별 KPI+배너 캐러셀+**전시 포스터 쇼케이스**+WISE 월캘린더(홀센터 동적 필터)+**역할별 내 할 일**+공지/빠른작업/활동+워크스페이스 그리드+ShellFooter+`/api/home/{todos,showcase,hall-centers,summary}` ②**테넌트 표준(소유자 지시)**: ten_id='KINTEX'(V31 재명명·코드 정규화·JWT 하위호환)+**tenant_id 선두 복합 PK**(V31 전환+V27/29/30/33/34/35 신규분 표준 적용) ③크롤: kintex.com 전시 34건+포스터+상세(주최·홈페이지·품목·소개, V32) ④W8: F100 AI라우터(Claude→Ollama 폴백·AiConfig)+F051 리드AI재계산+CMS AI번역 실배선·F005/6 부스판매(V27·원자 hold)·F035 BOQ+F084 옥션수수료 정산(V28)·F028 전자결재(V29·결재함 실배선·/work/approval) ⑤W9: F041/42/43 물류4탭(V33)·F085 환불룰(refund-v1.0)+F083 세금계산서·리포트(V34·G2 게이트)·F092 Text-to-SQL(7단계 가드·PII차단)+F025 규정챗봇·F099 웹훅(HMAC 인바운드·아웃바운드 스텁)+F052 팔로업EDM 트리거(V35). 검증: compileJava·test·tsc·vite 전부 EXIT 0. 잔여 게이트: PG/국세청/실발송(G2)·공사업체 통지 이메일(Postfix 승인 대기) | | 2026-07-12 | **7차 웨이브(MDI+테넌시2단계+i18n 스윕2)** — ①MDI 셸: 탭 문서 바(`MdiTabBar`+`mdiStore` sessionStorage·라우트 동기화·dedup·MAX 10·키보드 접근성·다크 토큰) 순증 레이어 — 잔여: DocumentHost 상태 보존(§2.7-3)·드래그 재정렬 ②테넌시 2단계: JWT `tid` 클레임(하위호환 폴백)·`EventAccessGuard` 테넌트 fail-closed(교차 테넌트 403 — `EventTenantMapper`+격리 테스트 3종)·전역 집계(analytics/admin/sysuser) 명시 스코핑 — company·audit_log는 Phase 3 후보 ③i18n 스윕2: 코어+admin 17화면·네임스페이스 30종(4개 언어) — 잔여 P2(도메인 모듈·work 공통)·P3(공유 ui) 목록=_workspace/impl_i18n_sweep2.md. 검증: tsc·vite·compileJava·test(75개) 전부 EXIT 0 | | 2026-07-12 | **5·6차 웨이브(NFR+멀티테넌시) + 운영 도메인 라이브** — ①W5-1 다크테마(global.css `--dk-*`→`[data-theme=dark]` remap·시스템 선호 폴백·themeStore(localStorage)·AppShell 토글·공유 CSS 하드코딩 hex 토큰화·focus-ring/reduced-motion/스킵링크) ②W5-2 i18n(react-i18next ko/en/zh/ja·공개 P1~P7+로그인/가입/비번찾기 전 문구·LanguageSwitcher(공개셸+로그인+AppShell 배선)·fallback ko) ③W6 멀티테넌시 1단계(V26 tenant 마스터+event/hall/app_user tenant_id 순증·TenantContextFilter(X-Tenant-Id→Host→kintex)·catalog/hall 스코핑·`/api/admin/tenants` GET/POST 실배선 — 회귀 0) ④**운영 도메인 https://kintex.wise.ai.kr 라이브**(위 §2 참조). 검증: tsc·vite·compileJava·test 전부 EXIT 0 | | 2026-07-12 | **4차 웨이브(백로그 4트랙) + ★OTP 로그인 루프 라이브 장애 수정** — ①**OTP 장애**(사용자 보고 "로그인 후 메인화면 안 나옴"): `verifyOtp`/`enrollVerify`가 코드 검증 **전에** 챌린지를 소비 → 오입력 1회면 이후 정답도 "세션 만료" 루프. 수정=성공 시에만 소비+실패 5회 한도(`OtpChallengeStore.recordFailure`)+`OtpChallengeStoreTest` ②SCR-14 현장운영 실전환(혼잡=체크인 파생·전력=utility 부하, IoT는 "센서 미연동" 정직 표기) ③M9 정산 신설(`/api/settlement`·V24 invoice/invoice_payment·`/settlement` 화면, ST_Area×요율+VAT 서버 권위·PG위임) ④M1 홀배정+자동견적(`/api/halls`·V25 기간 컬럼 순증·충돌 409+상세·rates-v1.0 견적·`/halls` 화면) ⑤공유 레이어: AuthUser.roleCode 순증 노출(JWT `rc` 클레임)+AdminGuard 정밀화+AppShell 플레이스홀더 네비 전량 배선(행사 컨텍스트 계산 경로)+`/_styleguide` prod 제외. 검증: compileJava·test·tsc -b --force·vite build 전부 EXIT 0 | diff --git a/docs/design.md b/docs/design.md index 8a6e59c..7c05a3e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md) -> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-11 · 버전: **v2.1.1** +> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-12 · 버전: **v2.1.2** > 근거 문서: `docs/PLANNING.md` **v3.0**(모듈 맵 M1~M18 + §5B 공통/시스템관리 레이어 + §1A 멀티테넌시, 6+2 역할, 나노바나나 표준 샷 세트 S1~S7) · `docs/FEATURE_BACKLOG_100.md`(100대 기능 F001~F100·NFR 4축) · Stitch 산출물 `stitch_kintex_ai_system_architect/`(18화면 + DESIGN.md 3변형) 학습·정합 > **단일 출처(Single Source of Truth)**: 프론트 구현 토큰·컴포넌트 계약은 본 문서가 권위다. Stitch 화면(code.html/screen.png)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다. > 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어. @@ -83,7 +83,7 @@ ``` 공통 -└─ 로그인 / 행사 워크스페이스 선택 (SCR-01) +└─ 로그인 (SCR-01) → 메인 홈 (SCR-HOME) — 요약 KPI·행사배너·전시일정 캘린더·공지·내 행사 워크스페이스(선택) 주최자 (Organizer) ├─ 대시보드 — D-데이 마일스톤 타임라인 (SCR-02) @@ -121,6 +121,11 @@ - 상단: 탭바 위에 얇은 유틸리티 바 — 브레드크럼(활성 탭 경로) + 알림 벨(마감·승인 이벤트) + "D-31" 행사 카운트다운 칩 상시 노출. - **단일 라우트 SPA → 탭 호스트로 전환**: 기존 "한 번에 한 화면" 라우팅을 폐기하고, 여러 문서(대시보드·배치 에디터·설계 스튜디오·배선 뷰·갤러리·승인 큐·검수 상세 등)를 탭으로 동시에 열어 상태를 보존한 채 전환한다. 상세 표준은 **§2.7 MDI 셸**을 따른다. - **웹(공개/관람객 사이트)**: **MDI 비적용.** 공개 홍보 사이트(`www/expo.`)·관람객 사전등록은 SEO·딥링크·공유가 목적이므로 전통적 페이지 내비(헤더 GNB + 단일 라우트)를 유지한다. +- **웹 인증 셸 하단 상태바/푸터(ShellFooter — v2.1.2 신설)**: 업무 포털 AppShell(§2.7)은 상단 유틸리티 바(top)·좌측 사이드바(left)에 더해 **전 셸 화면 공통의 얇은 하단 상태바(약 36px)**를 항상 노출한다. 사이드바 우측 전폭, DocumentHost 아래 고정(sticky bottom), 상단 1px 테두리 `neutral-200 #E4E7EC`, 배경 `neutral-050 #F9FAFB`(다크 토큰 대응 — 하드코딩 금지, CSS 변수 주입). + - **좌측**: 저작권 "© 2026 KINTEX. All rights reserved." (`neutral-500 #667085`, Caption 12px). + - **우측(또는 중앙-우 정렬)**: ① **시스템 상태 점** — 정상 `success #0E8A5F` 채운 점 + "시스템 정상", 점검 `warning #B45309` + "점검 중"(상태는 헬스 API 파생, 미연동 시 기본 "정상") ② **앱 버전** 칩 "v2.1.2"(tabular numerals·mono) ③ **고객지원 링크** "고객지원"(→ 지원 채널/문의, `primary-600 #0066B3`). + - **접근성**: 상태 점은 색 외 텍스트 라벨 병기(색만으로 상태 전달 금지), `role="contentinfo"`, 링크 포커스 링 `primary-600` 2px, 명도 대비 WCAG AA. i18n 키 `shell.footer.*`. + - **모바일**: 셸 푸터 미적용(하단 탭 바가 그 자리를 차지) — 버전·상태·지원은 "더보기"(SCR-M3 계열)로 이관. - **모바일**: 하단 탭 바 4개 — 홈 / 내 부스(또는 승인 큐) / 알림 / 더보기. 화면 전환은 네이티브 스택 내비(모바일은 MDI 미적용 — 단일 화면 포커스). 캔버스 화면은 뷰어 모드. --- @@ -199,10 +204,13 @@ PLANNING v2.0/v3.0에서 도메인 모듈(M10~M18)이 정식 편입되며, Stitc │ (메뉴) │ DocumentHost (활성 탭 문서) │ │ │ 비활성 탭은 언마운트하지 않고 │ │ │ display:none 로 상태 보존 │ -│ │ │ +│ ├─────────────────────────────────────────────────┤ +│ │ [ShellFooter] © 2026 KINTEX … ● 시스템 정상 · v2.1.2 · 고객지원 │ 높이 36px └──────────┴─────────────────────────────────────────────────┘ ``` +- **ShellFooter(높이 36px)**: 사이드바 우측 전폭 하단에 고정(sticky). 좌측 저작권, 우측 시스템 상태 점·앱 버전·고객지원 링크. 상세 규격은 §2-2 "웹 인증 셸 하단 상태바/푸터"를 따른다(전 셸 화면 공통·§1 토큰·WCAG AA). + - **사이드바(240px)**: 기존 역할별 메뉴 유지. 항목 클릭 → 해당 문서 탭 열기(중복 방지 시 기존 탭 활성). 활성 문서에 해당하는 메뉴 항목은 `primary-100 #D9EAF7` 배경으로 하이라이트. - **탭바(높이 40px)**: 사이드바 우측 상단, 유틸리티 바 아래 전폭. 문서 탭이 좌→우로 나열되고, 우측 끝 `+`(빠른 열기 팔레트). 탭바 배경 `neutral-050 #F9FAFB`, 하단 1px 테두리 `neutral-200`. - **DocumentHost(중앙)**: 활성 탭의 문서만 시각적으로 표시. **비활성 탭은 DOM에서 언마운트하지 않고 `display:none`(또는 offscreen)으로 유지**해 스크롤·폼·에디터 상태를 보존한다(§2.7-3). 배경은 화면별(대시보드=`neutral-050`, 에디터/배선 뷰 캔버스 영역=`canvas-bg #1C2536`). @@ -324,7 +332,8 @@ An enterprise MDI (multiple document interface) application shell for "KINTEX AI | SCR | 화면명 | 모듈 | 역할 | P | 상태 | |---|---|---|---|---|---| -| SCR-01 | 로그인 & 행사 워크스페이스 선택 | 공통 | 전 | P0 | ✅Stitch ✅구현 | +| SCR-01 | 로그인 (성공 시 SCR-HOME 랜딩) | 공통 | 전 | P0 | ✅Stitch ✅구현 | +| SCR-HOME | 로그인 후 메인 홈(랜딩) | 공통·§5B·M1·M12 | 전 | P0 | 🔲미생성 | | SCR-02 | 주최자 대시보드 | M1·M6 | 주 | P0 | ✅Stitch ✅구현 | | SCR-03 | 부스 배치 에디터 | M2 | 주·홀 | P0 | ✅Stitch ✅구현 | | SCR-04 | 배치안 비교(S7) | M2·M5 | 주 | P0 | ✅Stitch ✅구현 | @@ -424,7 +433,7 @@ An enterprise MDI (multiple document interface) application shell for "KINTEX AI | SCR-M14 | 모바일 티켓 예매 | M10·M9 | 대 | ✅Stitch | | SCR-M15 | 내 티켓 지갑 | M10·M9 | 대 | ✅Stitch | -**집계**: 웹 코어·도메인 51 (기존 17 + 신규 34) · 관리자 10 (신규) · 공개사이트 8 (기존 6 + 신규 2 티켓) · 모바일 15 (기존 3 + 신규 12) = **총 84화면**(기존 유지 20 + 신규 64). 상태: ✅Stitch 83 · ✅구현 20 · 🔲미생성 0 (2026-07-12 수확으로 신규 64화면 전량 Stitch 확보 — Stitch 미확보는 SCR-M3(✅구현(부분)) 1건뿐). +**집계**: 웹 코어·도메인 52 (기존 17 + 신규 34 + **SCR-HOME 신규 1**) · 관리자 10 (신규) · 공개사이트 8 (기존 6 + 신규 2 티켓) · 모바일 15 (기존 3 + 신규 12) = **총 85화면**(기존 유지 20 + 신규 65). 상태: ✅Stitch 83 · ✅구현 20 · 🔲미생성 2 (SCR-HOME 신규 정의(Stitch 착수 전) + SCR-M3(✅구현(부분))). 2026-07-12 수확으로 SCR-HOME을 제외한 신규 화면은 전량 Stitch 확보됨. --- @@ -437,11 +446,11 @@ An enterprise MDI (multiple document interface) application shell for "KINTEX AI ### SCR-01 로그인 & 행사 워크스페이스 선택 - **대상**: 전 역할 공통 · **진입 경로**: 최초 접속 / 초대 링크 클릭 +- **랜딩 흐름 변경(v2.1.2)**: **로그인 성공 → 메인 홈 `/home`(SCR-HOME)으로 랜딩한다.** 기존 "로그인 후 행사 워크스페이스 카드 그리드" 단계(phase)는 **제거**하고, 워크스페이스 선택은 SCR-HOME "내 행사 워크스페이스" 섹션으로 흡수한다. SCR-01은 순수 로그인·인증 화면으로 축소된다. - **레이아웃 (위→아래)**: 1. 좌우 분할: 좌측 55% 브랜드 패널 — 나노바나나 생성 부스 예상 사진 배경 + 카피 "신청서를 내는 순간, 시공 후 사진을 먼저 봅니다" + AI 이미지 고지 캡션 - 2. 우측 45% 로그인 카드: 이메일/비밀번호, 이메일 인증 로그인, 킨텍스 내부 계정(SSO) 버튼 분리 - 3. 로그인 후: 행사 워크스페이스 카드 그리드(행사명, 기간, 홀, 내 역할 배지, D-데이 칩) + 초대 코드 입력 필드 -- **핵심 컴포넌트/상태**: 역할 배지(주최자/참가업체/장치업체/홀매니저) · 빈 상태: "참여 중인 행사가 없습니다 — 초대 링크를 확인하세요" · 에러: 미등록 장치업체 초대 수락 시 차단 모달("킨텍스 등록업체만 참여할 수 있습니다"). + 2. 우측 45% 로그인 카드: 이메일/비밀번호, 이메일 인증 로그인, 킨텍스 내부 계정(SSO) 버튼 분리 + 하단 링크 "초대 코드로 참여하기"(코드 입력 모달) +- **핵심 컴포넌트/상태**: 로그인 실패 인라인 에러 · 에러: 미등록 장치업체 초대 수락 시 차단 모달("킨텍스 등록업체만 참여할 수 있습니다"). **역할 배지·워크스페이스 카드 그리드·"참여 중인 행사가 없습니다" 빈 상태·초대 코드 안내는 SCR-HOME으로 이관**(아래 §SCR-HOME 참조). - **Stitch 프롬프트**: ``` @@ -450,6 +459,46 @@ A B2B SaaS login page for "KINTEX AI 전시관리", a Korean exhibition manageme --- +### SCR-HOME 로그인 후 메인 홈 (랜딩 대시보드) + +- **화면 ID / 이름**: SCR-HOME / 메인 홈(랜딩) · **대상**: 전 역할 공통(로그인 사용자) · **진입 경로**: 로그인 성공(SCR-01) → `/home` · **셸**: 업무 인증 AppShell(§2.7 MDI) 내부 문서(핀 고정 홈 탭). 사이드바·상단 유틸리티 바·**하단 ShellFooter**(§2-2) 공통 노출. +- **밀도 원칙(소유자 지시 2026-07-12 — 대형 공백 금지)**: SCR-HOME은 **화면이 콘텐츠로 꽉 차야 한다(large empty area 금지)**. ①**12컬럼 그리드 컴팩트 배치** — 섹션 간 세로 여백은 §1-4 토큰 준수 범위 내 최소(거터 24px 기준, 섹션 간 24px 표준·32px 초과 금지), 카드 내부 패딩은 고밀도 기준 8~16px. ②**배너 캐러셀 높이 과대 금지 — 약 220~260px**. ③**좌우 컬럼 세로 균형** — 우측 4/12는 공지만으로 좌측(캘린더+리스트) 높이에 못 미치므로 보조 위젯("내 할 일"·"빠른 작업"·"최근 활동")으로 채운다(아래 4-우). ④**빈 상태도 공간 활용** — 모든 빈 상태는 안내 문구 + **행동 버튼(CTA)** 조합으로 배치해 죽은 영역을 만들지 않는다. +- **레이아웃 (위→아래, 웹 1440px · 12컬럼 컴팩트)**: + 1. **인사·요약 KPI 스트립(전폭, 단일 행)**: 좌측 인사 "안녕하세요, {이름}님" + 오늘 날짜/역할 배지(주최자/참가업체/장치업체/홀매니저/관리자). 우측에 **요약 KPI 타일 4개** — ① 진행 중 전시 수 ② 오늘 체크인 ③ D-임박 행사(가장 가까운 개장 D-데이 칩) ④ 미결 결재/승인 건수. 각 타일은 §1-4 상태색 좌측 4px 액센트, 클릭 시 해당 문서 탭 열기(딥링크). **KPI 4타일은 역할(사용자 그룹)별 구성이다(확정판 명시)** — 예시 세트: **주최자** = 진행 중 전시 · 참가업체 신청 · D-임박 마감 · 미결 결재 / **참가업체** = 내 부스 진행 상태 · 제출할 서류 · 유틸리티 마감 D-n · 미확인 견적/청구 / **공사(장치)업체** = 응찰 중 옥션 · 수주 부스 · 시공 일정 D-n · 검수 대기 / **관리자** = 승인 대기 · 활성 사용자 · 시스템 알림 · 점검 항목 (기본 예시는 주최자 세트). 데이터 없는 타일은 "—" 플레이스홀더. **인사와 KPI는 같은 행에 컴팩트 배치**(세로 분리 금지). + 2. **행사소개 배너 캐러셀(전폭, 높이 220~260px 고정)**: 관리자 등록 이미지 슬라이드(SCR-01 브랜드 패널과 **동일한 login-slides 자산/API 재사용**). 좌우 내비 화살표(선 SVG)·인디케이터 점·자동 슬라이드(5초, hover/포커스 시 일시정지·`prefers-reduced-motion` 시 자동재생 정지). 각 슬라이드에 제목·부제·CTA(행사 상세/사전등록 딥링크). AI 생성 이미지가 포함되면 §1-4 고지 캡션 유지. **높이는 220~260px으로 제한**(과대 히어로 금지 — 밀도 원칙 ②). + 3. **"진행 중·다가오는 전시" 쇼케이스(전폭, 가로 스크롤 포스터 카드 레일 — 확정판 추가)**: 다양한 행사 전시정보를 한눈에 노출. 배너 캐러셀 바로 아래(캘린더 위) 배치. + - **헤더 행**: 섹션 제목 "진행 중·다가오는 전시" + **탭/세그먼트 전환 `진행 중 | 이번 달 | 다음 달`**(기본값 진행 중) + 우측 끝 "전체 보기" 링크(전시 일정 진입). + - **포스터 카드 레일**: 가로 스크롤 1행에 **8~12장 노출**(1440 기준 화면당 6~7장 가시 + 나머지 스크롤). **좌우 스크롤 화살표 버튼**(선 SVG, 끝단 도달 시 해당 방향 비활성) + 휠/드래그/터치 스와이프. 카드 구성 = **포스터 이미지(세로 비율 약 2:3)** 상단 + 하단 정보 블록: 행사명(2줄 말줄임) · 기간 · 홀(hall_label) · **상태 배지(진행중/예정)** · **D-day 칩**. 카드 클릭 → 참여 행사면 해당 워크스페이스, 아니면 행사 상세. + - **포스터 폴백**: `poster_url` 없는 행사는 **브랜드 그라디언트(primary 계열) + 행사명 타이포 폴백 카드**(빈 이미지 영역 금지 — 밀도 원칙 ④ 동일 정신). + - 레일 전체 높이 약 280~320px 이내 유지(밀도 원칙 — 캘린더를 과도하게 아래로 밀지 않음). 키보드 좌우 화살표로 카드 포커스 이동, 레일은 `role="list"`/카드 `role="listitem"`. + 4. **본문 2열 그리드(태블릿 2열→1열)**: + - **좌 8/12 — 현재·예정 전시 일정(달력)**: **WISE(UIWS) 캘린더 패턴 참조** — 레퍼런스 `C:\GUARDiA\workspace\uiws\frontend\src\components\CalendarView.tsx`(+ `pages/schedule` 계열)의 레이아웃·상호작용 컨벤션을 따른다: + - **월 캘린더 그리드**(6주 × 7열) + 상단 요일 헤더 행(일~토, 일=적/토=청 색상). 상단 툴바 "이전 / 다음 / 오늘" + (선택) 월·주 뷰 전환. **오늘 셀 강조**, 다른 달 셀은 흐리게(out). + - **일자별 이벤트 표시**: 단일일 전시는 셀 안 **색점(상태) + 제목 칩**, **기간(2일 이상) 전시는 날짜에 걸친 가로 이벤트 바(spanning bar, 주 단위 분할·레인 배치)**로 표시. 셀 초과 시 "+N" → 그 일자 전체 일정 팝업(WISE `moreDate` 팝업 동일). 상태 색점 범례를 캘린더 상단에 노출. + - **전시장 선택 필터(세그먼트 토글 — 동적 옵션, 하드코딩 금지)**: 캘린더 헤더에 세그먼트 컨트롤. 옵션은 **`전체` + 홀 마스터 테이블의 전시장(센터) 구분 distinct 목록**을 동적으로 렌더한다(기본값 **전체**). 현재는 제1전시장(1관)·제2전시장(2관)이지만, **추후 3관·4관 신설 시 마스터에 추가되면 세그먼트가 자동 확장**된다(코드에 관/홀 범위를 고정하지 않음). 표시 순서는 마스터 정렬을 따른다. 선택한 전시장의 홀을 점유하는 행사만 캘린더 이벤트·하단 리스트에 필터링(WISE `reloadKey` 재조회 패턴 — 세그먼트 변경 시 범위 재조회). 옵션이 많아지면(2관 초과) 세그먼트 폭 초과 시 드롭다운/스크롤로 폴백. + - 하단에 **이번 주/진행 중 전시 리스트**(행사명·홀·기간·상태 배지·D-데이 칩, 행 클릭 시 행사 대시보드/상세 진입) — 세그먼트 필터와 동기. + - 데이터: `/api/events/calendar`(점유·기간 바) + `/api/public/events`(리스트) 재사용, 홀 필터 파라미터로 전시장 세그먼트 반영. + - **우 4/12 — 세로 스택 4위젯(좌측 컬럼과 세로 균형 — 밀도 원칙 ③)**: + 1. **내 할 일(역할 기반 — 확정판 추가, 최상단)**: 로그인 사용자가 **지금 해야 할 작업**을 한눈에 보여주는 카드. 카드 헤더 "내 할 일" + **건수 합계 배지**(예: "7"). **역할별 항목 구성** — 주최자 = 미결 결재 · 서류 마감 임박(D-n) · 홀 배정 확정 대기 · 옥션 낙찰 대기 / 참가업체 = 제출할 신고서류 · 부스 설계 확정 · 견적/청구 확인 / 공사(장치)업체 = 응찰 마감 임박 · 수주 시공 일정 / 관리자 = 승인 대기 · 시스템 점검 항목. **각 행 = 상태 점 + 작업명 + 대상(행사/부스/문서명) + D-day 또는 기한** — 클릭 시 해당 화면(문서 탭)으로 이동(§2.7 딥링크·중복 방지). D-3 이내 `warning`·기한 초과 `error` 색. 정렬은 기한 임박순, 최대 6행 + "전체 보기". **0건 시 "모두 완료" 상태**(success 체크 아이콘 + "처리할 작업이 없습니다") + 행동 버튼(예: "결재함 열기") — 밀도 원칙 ④. + 2. **공지사항**: §5B notice 모듈 최근 5건 리스트(제목·작성일·NEW 배지·중요 고정 핀), 하단 "더보기" → `/work/notice`(SCR-42). 각 항목 클릭 시 공지 상세. + 3. **빠른 작업(바로가기)**: 역할별 주요 액션 아이콘 버튼 2×3 그리드(선 SVG + 짧은 라벨) — 예: 주최자 = 배치 에디터·참가업체 초대·서류 워크플로·정산·공지 작성·통합검색 / 참가업체 = 설계 스튜디오·유틸리티 신청·갤러리·옥션·쪽지·검색. 클릭 = 해당 문서 탭 열기(딥링크, §2.7 중복 방지). 구성은 역할 기본값 + 정적 정의(개인화는 후속). + 4. **최근 활동**: 내 계정 관련 최근 이벤트 타임라인 5건(승인/반려·공지 등록·쪽지 수신·마감 임박 등 — 기존 알림 피드 데이터 재사용, SCR-47 알림센터와 동일 원천). 각 행 = 상태 색점 + 요약 텍스트 + 상대시각("2시간 전"), 클릭 시 해당 문서 이동. 하단 "전체 보기" → 알림센터. + 5. **내 행사 워크스페이스(전폭)**: "내 행사" 섹션 제목 + **참여 행사 카드 그리드**(행사명·기간·홀·내 역할 배지·D-데이 칩·진행 상태 요약). **카드 클릭 → 해당 행사 워크스페이스(역할별 대시보드) 진입**(주최자=SCR-02, 참가업체=SCR-05 등). SCR-01에서 이관된 워크스페이스 선택 기능이 이 섹션으로 흡수됨. 참여 행사가 없으면 **빈 상태**: "참여 중인 행사가 없습니다 — 초대 링크 또는 초대 코드를 확인하세요" + 초대 코드 입력 필드(SCR-01 기존 문구·컴포넌트 재사용). +- **핵심 컴포넌트/상태**: + - 컴포넌트: KPI 타일(StatusBadge 액센트)·이미지 캐러셀·**쇼케이스 포스터 레일(PosterRail — 포스터 카드/폴백 카드·스크롤 화살표·탭 전환)**·월 캘린더·전시 리스트·**내 할 일 리스트(TodoList — 건수 배지·기한 색상)**·NoticeList·**빠른 작업 그리드(QuickActions)·최근 활동 타임라인(ActivityFeed)**·워크스페이스 카드·DdayChip·역할 배지 — 모두 §1 기존 컴포넌트 재사용, 신규 토큰 0. + - **로딩**: 각 영역 독립 스켈레톤(KPI 4타일·배너·**쇼케이스 포스터 카드 6장**·캘린더·**내 할 일**·공지·빠른 작업·최근 활동·워크스페이스 그리드) — 부분 로드 허용(영역별 개별 로딩). + - **빈 상태(전부 안내+행동 버튼 조합 — 밀도 원칙 ④)**: 배너 미등록 시 기본 그라디언트 + 브랜드 카피 폴백(SCR-01 카피 재사용) + CTA "행사 둘러보기" · **쇼케이스 탭 0건 "해당 기간 전시가 없습니다" + "전체 일정 보기"**(개별 카드의 포스터 부재는 그라디언트+행사명 폴백 카드) · **내 할 일 0건 "모두 완료" 상태(success 체크 + "처리할 작업이 없습니다") + 행동 버튼("결재함 열기" 등 역할별)** · 공지 0건 "등록된 공지가 없습니다" + "전체 공지 보기"(권한자는 "공지 작성") · 워크스페이스 0건 초대 안내 + 초대 코드 입력 필드 + "초대 코드 확인" 버튼 · 일정 0건 "예정된 전시가 없습니다" + "전체 일정 보기" · 최근 활동 0건 "최근 활동이 없습니다" + "알림 설정". + - **에러**: 영역별 인라인 에러 카드 + 재시도(전면 에러 금지 — 한 영역 실패가 홈 전체를 막지 않음). KPI/캘린더 API 실패 시 해당 카드만 "불러오지 못했습니다 · 다시 시도". +- **데이터 소스 매핑**: ①KPI = 기존 실 API 재사용(진행 전시=이벤트 목록, 오늘 체크인=M10 체크인 집계, D-임박=이벤트 캘린더 파생, 미결 결재=승인/결재 집계) ②배너 = **기존 login-slides 자산/API 재사용**(SCR-01과 단일 출처) ③일정 = `/api/events/calendar` + `/api/public/events`, **전시장 필터 옵션 = 홀 마스터 조회 API(센터/전시장 구분 그룹, distinct·정렬 순)** — 세그먼트 옵션은 이 API 응답으로 동적 구성(하드코딩 금지, 3관·4관 확장 대비), 선택값은 캘린더·리스트 조회에 홀/센터 필터 파라미터로 전달 ④**쇼케이스 = event 테이블 `poster_url`(V32 마이그레이션 크롤 적재분·34건) + 기간(start/end) + `hall_label`** — 탭별 필터(진행 중=오늘 포함 기간 / 이번 달·다음 달=해당 월 개장), `poster_url` NULL이면 그라디언트+행사명 폴백 카드 ⑤**내 할 일 = 결재함(§5B approval)·서류 마감(M6)·옥션(M15)·정산(M9) 집계 API 조합** — 프론트 개별 호출 조합 대신 **신규 통합 API `GET /api/home/todos` 계약 제안**: 응답 `{ total: number, items: [{ type: 'approval'|'document'|'auction'|'settlement'|'system', title, target, dueDate?, dday?, severity: 'normal'|'warning'|'overdue', route }] }` — 역할(JWT)별 서버 사이드 구성·기한 임박순 정렬·최대 6건+total(백엔드 kintex-backend-dev 소관, 계약만 정의) ⑥공지 = §5B notice 최근 5건(`/work/notice` 목록 API) ⑦빠른 작업 = 역할별 정적 정의(라우트 레지스트리 — API 불필요) ⑧최근 활동 = 기존 알림/이벤트 피드 API 재사용(SCR-47 알림센터와 동일 원천, 최근 5건) ⑨워크스페이스 = 기존 워크스페이스(참여 행사) 데이터. 미연동 지표는 SCR-02 원칙대로 샘플 표기. +- **접근성·반응형·국제화**: 1440 기준, 태블릿 본문 2열→1열, 모바일은 SCR-M3(모바일 홈)로 분기. 라이트/다크 토큰(CSS 변수 주입·하드코딩 hex 금지). i18n **`home.` 네임스페이스** 전제(예 `home.greeting`·`home.kpi.*`·`home.showcase.*`·`home.todo.*`·`home.notice.more`·`home.workspace.empty`). **이모지 금지 — 전 아이콘 선 SVG**(stroke, `fill:none`). 캐러셀 자동재생은 일시정지 컨트롤 제공·`prefers-reduced-motion` 준수, 캘린더 점유는 색 외 텍스트/패턴 병기, KPI·리스트 `aria-label`, 포커스 링 `primary-600` 2px, 명도 대비 WCAG AA 이상. +- **Stitch 프롬프트**: + +``` +A post-login landing home dashboard for "KINTEX AI 전시관리", a Korean B2B exhibition management web app, using the "Precision Enterprise AI" design system. Desktop 1440px, light theme, primary blue #0066B3, AI accent purple #6D4AFF, backgrounds #F9FAFB, white cards with 1px #E4E7EC borders, success green #0E8A5F, warning amber #B45309, Korean sans-serif (Pretendard style), 8px card radius, dense professional enterprise tone. All UI text in Korean. NO emojis — use thin line (stroke) SVG icons only. The page sits inside an app shell: a fixed 240px left sidebar with an event switcher and vertical menu, a slim 48px top utility bar with breadcrumb, notification bell and a countdown chip "개장 D-31", AND a thin 36px bottom status FOOTER spanning the content width with left-aligned copyright "© 2026 KINTEX. All rights reserved." and right-aligned items: a small green status dot with label "시스템 정상", an app version chip "v2.1.2", and a link "고객지원". IMPORTANT — DENSE LAYOUT: the page must be completely filled with content, NO large empty areas, compact 12-column grid, minimal vertical gaps (24px) between sections, tight card padding. Main content top: a single compact row combining a greeting "안녕하세요, 김주최님" with a date and a role badge on the left, and 4 KPI stat tiles with left color accents on the right of the SAME row — "진행 중 전시 3", "오늘 체크인 1,240", "임박 행사 D-6" (amber), "미결 결재 5" (blue). Below: a full-width image CAROUSEL of exhibition promo banners, constrained to about 240px height (NOT a tall hero), with left/right arrow controls, dot indicators, and a slide title, subtitle and a CTA button. Directly below the carousel, a full-width "진행 중·다가오는 전시" SHOWCASE section: a section header row with the title, a small segmented tab control "진행 중 | 이번 달 | 다음 달" ("진행 중" selected) and a "전체 보기" link on the right; under it a HORIZONTAL SCROLLING RAIL of 8+ exhibition poster cards (about 300px tall total) with left/right scroll arrow buttons at the rail edges — each card shows a portrait-ratio (2:3) exhibition poster image on top and below it the event name (2-line ellipsis), dates, hall label like "제2전시장 홀7", a status badge "진행중" or "예정", and a D-day chip like "D-12"; one or two cards have NO poster and instead show a blue gradient placeholder with the event name in white typography as fallback. Below the showcase rail, a two-column body: left 8/12 a "전시 일정" card containing a FULL MONTH CALENDAR GRID (6 weeks × 7 columns) with a weekday header row "일 월 화 수 목 금 토" (Sunday red, Saturday blue), a toolbar with "이전 / 다음 / 오늘" and the current month title, today's cell highlighted; each day cell shows exhibition events as small colored status dots with title chips, and MULTI-DAY exhibitions appear as horizontal event BARS spanning across the dates they occupy (with a "+2" more indicator on busy days) — like a Google-Calendar-style month view. Above the calendar grid, a SEGMENTED control filter whose options are DYNAMIC (data-driven from a hall master table, not hardcoded): the first option "전체" (selected by default) followed by one segment per exhibition center — currently "제1전시장(1관)" and "제2전시장(2관)", but drawn as a variable count (2 + N, extensible to 3관/4관 in future) — which filters the calendar events and the list below by exhibition-hall complex. Beneath the calendar, a list of this-week/ongoing exhibitions with hall, dates, status badges and D-day chips. Right 4/12 a vertical stack of FOUR cards that together match the height of the left column: (1) at the TOP a "내 할 일" (my tasks) card with a count badge "7" in the header, listing role-based task rows — each row has a small colored status dot, a task name, a target and a D-day/due chip, e.g. "결재 대기 · 부스 설계 승인 · A-102 · D-1" (amber), "신고서류 제출 · 전기안전 신고 · D-3" (amber), "홀 배정 확정 대기 · 2026 로보월드", "옥션 낙찰 선정 · 부스 시공 · D-5", and a "전체 보기" link; (2) a "공지사항" card listing 5 recent notices with dates and a NEW badge and a "더보기" link at the bottom; (3) a "빠른 작업" card with a 2×3 grid of shortcut buttons, each a thin line SVG icon with a short Korean label like "배치 에디터, 참가업체 초대, 서류 워크플로, 정산, 공지 작성, 통합검색"; (4) a "최근 활동" card with a compact timeline of 5 recent activity rows, each a small colored status dot, a short Korean summary like "부스 설계 승인됨 · A-102" and a relative time "2시간 전", with a "전체 보기" link. At the very bottom of the content, a full-width "내 행사 워크스페이스" section with a grid of event workspace cards, each showing event name, period, hall, a role badge and a D-day chip, clickable to enter that event. Every empty state in this design shows a message plus an action button (never a blank area). Dense, data-rich, professional B2B dashboard aesthetic with no wasted vertical space. +``` + +--- + ### SCR-02 주최자 대시보드 (히어로: D-데이 마일스톤 타임라인) - **대상**: 주최자 · **진입 경로**: 로그인 → 행사 선택 (주최자 기본 홈) @@ -1617,6 +1666,7 @@ A mobile "my ticket wallet" screen for an exhibition visitor, 390px width, light | 버전 | 일자 | 작성자 | 내용 | |---|---|---|---| +| **v2.1.2** | 2026-07-12 | designer | **로그인 후 메인 홈(SCR-HOME) 신설 + 로그인 랜딩 흐름 변경 + 셸 하단 상태바(ShellFooter) 신설(소유자 지시 2026-07-12).** ①**§3 SCR-HOME 신설**(라우트 `/home`, AppShell 셸 내부 핀 홈 탭) — 인사·요약 KPI 스트립 4타일(진행 전시·오늘 체크인·D-임박·미결 결재, 기존 실 API 재사용), 행사소개 배너 캐러셀(**SCR-01 login-slides 자산/API 재사용**·자동슬라이드·좌우 내비), 현재·예정 전시 일정 캘린더(**WISE `CalendarView` 패턴 참조** — 월 그리드 6주×7열·요일 헤더·오늘 강조·단일일 색점칩/기간 spanning bar·"+N" 팝업 + **전시장 세그먼트 필터(동적 옵션·하드코딩 금지 — `전체`+홀 마스터 센터 구분 distinct, 3관·4관 확장 대비)** 기본값 전체, `/api/events/calendar`+`/api/public/events`+홀 마스터 옵션 API 재사용), **"진행 중·다가오는 전시" 쇼케이스(확정판 — 배너 아래 가로 스크롤 포스터 카드 레일 8~12장·세로 2:3 포스터+행사명/기간/hall_label/상태배지/D-day·좌우 화살표·탭 `진행 중|이번 달|다음 달`·poster_url 부재 시 그라디언트+행사명 폴백, 데이터=event 테이블 poster_url V32 크롤 34건)**, 우측 컬럼 4위젯 스택(**내 할 일(역할 기반, 최상단) — 주최자=미결 결재/서류 마감 D-n/홀 배정 대기/옥션 낙찰 대기·참가업체=신고서류/설계 확정/견적·청구·공사업체=응찰 마감/시공 일정·관리자=승인 대기/점검, 행=상태점+작업명+대상+D-day 클릭 이동·건수 배지·0건 "모두 완료"+CTA, 데이터=§5B approval·M6·M15·M9 집계 조합 — 신규 통합 API `GET /api/home/todos` 계약 제안 명시**·공지 최근 5건+더보기(§5B notice `/work/notice`)·**빠른 작업 2×3 바로가기 그리드(역할별)**·**최근 활동 타임라인 5건(알림 피드 재사용)**), 내 행사 워크스페이스 카드 그리드(참여 행사·카드 클릭→역할별 대시보드·빈 상태 초대코드 안내) + **KPI 4타일 역할별 구성 명시(주최자/참가업체/공사업체/관리자 예시 세트)** + **밀도 원칙 4항(소유자 추가 지시)**: 대형 공백 금지·12컬럼 컴팩트(섹션 간 24px 표준/32px 상한)·배너 높이 220~260px 제한·좌우 컬럼 세로 균형·전 빈 상태 안내+행동 버튼(CTA) 조합. **본 판이 SCR-HOME 스펙 확정판** — 레이아웃·상태(영역별 로딩/빈/에러)·데이터 원천 매핑·접근성(WCAG AA·i18n `home.`·이모지 금지 선SVG·`prefers-reduced-motion`)+영어 Stitch 프롬프트(Precision Enterprise AI·1440·footer 포함). ②**SCR-01 흐름 수정**: 로그인 성공→`/home` 랜딩, "행사 워크스페이스 선택" phase 제거(SCR-HOME 섹션으로 흡수), SCR-01은 순수 로그인 화면으로 축소. ③**§2-2 ShellFooter 신설**(약 36px, 전 셸 화면 공통): 좌측 저작권 "© 2026 KINTEX. All rights reserved.", 우측 시스템 상태 점(정상/점검·색외 텍스트 병기)·앱 버전·고객지원 링크, `role="contentinfo"`·라이트/다크 토큰·WCAG AA·i18n `shell.footer.*`, 모바일 미적용(더보기 이관). §2.7-1 셸 레이아웃 다이어그램에 하단 36px 행 추가. ④**§2-1 사이트맵·§2.8-2 인벤토리**에 SCR-HOME 반영(집계 84→**85화면**, 미생성 0→2: SCR-HOME+SCR-M3). §1 토큰 권위 유지·**신규 토큰 0**, 기존 화면 스펙·PLANNING.md·src 무수정 | | **v2.1.1** | 2026-07-12 | designer | **Stitch 잔여 39화면 전량 확보(64/64) 상태 반영 + 이전 확보 25화면 상태 정합 + 집계 갱신.** 2026-07-12 Stitch 수확 재실행으로 미확보 39화면이 전량 확보됨 — §2.8-2 인덱스 상태 컬럼만 `🔲미생성`→`✅Stitch` 교체(웹코어 SCR-22·23·25·26·27·29·30·32~51, 관리자 SCR-A5·A6·A8·A9, 공개 SCR-P1~P7, 모바일 SCR-M15). 추가로 이전 세션 확보분 25화면(디스크 code.html 존재 검증: 웹코어 SCR-18·19·20·21·24·28·31, 관리자 SCR-A1~A4·A7·A10, 공개 SCR-P8, 모바일 SCR-M4~M14)도 동일 규칙으로 정합화 — 총 64화면 상태 교체. §2.8-2 집계 문장 갱신: ✅Stitch 18→**83** · 🔲미생성 64→**0**(Stitch 미확보는 SCR-M3 1건뿐). 화면 스펙·프롬프트·구조 무수정. 수확 상세 `_workspace/stitch_gen/harvest_report.md` | | **v2.1** | 2026-07-11 | designer | **입장권 예매 4화면 추가(M10 티켓 기획 대비 화면 갭 해소).** PLANNING v3.0 M10이 티켓을 기획 범위에 포함하나 design.md v2.0 인벤토리엔 예매 흐름 화면이 없던 갭을 메움 — 기존 번호 체계 이어서 순증(재번호화·기존 스펙 수정 없음). ①**§2.8-1 매트릭스**: M10 행에 SCR-P7·P8/M14·M15, M9 행에 SCR-P7(티켓결제 연계) 반영. ②**§2.8-2 인덱스**: 공개사이트에 SCR-P7(입장권 예매)·SCR-P8(예매 확인·취소), 모바일에 SCR-M14(모바일 티켓 예매)·SCR-M15(내 티켓 지갑) 추가, 집계 80→**84화면**(공개 6→8·모바일 13→15, 미생성 60→64). ③**§3B**: SCR-P7(행사·권종→예매자→결제→완료 다단계, 게스트+회원, 결제 PG 어댑터 위임·카드번호 입력 UI 없음)·SCR-P8(예매번호+이메일 조회·QR 티켓 상세·환불정책·취소) 상세+영어 Stitch 프롬프트. ④**§4**: SCR-M14(SCR-P7 모바일 최적화 390px·48px 터치)·SCR-M15(보유 티켓·QR 풀스크린 밝기부스트·오프라인 표시·취소 진입, 티켓→체크인→배지(SCR-M5) 전환 명시) 상세+프롬프트. §5 가이드 모바일 범위 M1~M13→M1~M15. **보안·불변 준수**: 결제 화면 카드번호 저장 UI 금지(PG 위임 명시)·PII 마스킹·WCAG AA·i18n·라이트/다크. §1 토큰 권위 유지, 신규 토큰 0. PLANNING.md·기존 화면 스펙·src 무수정 | | **v2.0** | 2026-07-11 | designer | **전 화면 인벤토리 확장 — PLANNING v3.0 전 모듈·전 역할 커버.** 기존 SCR-01~17·SCR-M1/M2(재번호화 없이 보존)에 **신규 60화면 순증**: ①**§2.8 전 화면 인벤토리 매트릭스 신설**(모듈×역할×채널 요약표 + 전 SCR 인덱스 + 기생성/기구현/미생성 상태 컬럼, 총 80화면). ②**웹 코어·도메인 SCR-18~51**(M1 배정·견적/부스판매/셀프선택, M9 정산, M6 서류·마일스톤·서류작성, M7 매칭·RFQ, M8 물류, **M15 옥션 4종**(목록·상세순위·견적서·낙찰), M10 관람객관리·체크인·리드, M12 EDM·스폰서십, M17 CMS·마이크로사이트·다국어, 업체 대시보드, **§5B 공통 업무기능 SCR-39~48**(worklog·schedule·message·notice·opinion·search·meeting·report·notification·mypage, UIWS 표준 정합), 인증 SCR-49~51(회원가입·비번재설정·OTP)). ③**§3A 관리자 백오피스 SCR-A1~A10**(사용자·RBAC·공통코드·메뉴·감사로그·시스템설정·마스터데이터·룰셋버전·**테넌트 온보딩§1A**·AiConfig). ④**§3B 공개 홍보 사이트 SCR-P1~P6**(홈·행사상세·인터랙티브 플로어플랜·사전등록·마이크로사이트 공개뷰·참가문의, MDI 비적용·SEO/다국어). ⑤**모바일 SCR-M3~M13**(역할별 홈·리드캡처·관람객 배지/QR·wayfinding·플로어플랜·비즈매칭·세션아젠다·업체 통행증·알림·서류승인·옥션순위). 각 화면 SCR ID·화면명·역할·모듈·레이아웃·상태(로딩/빈/에러/degraded)·데이터 원천(실/샘플 경계)·라우트·**영어 Stitch 프롬프트** 포함. §1 토큰·§2.7 MDI 셸 보존, **신규 토큰 0**. AI 산출물 워터마크·AiLabel·PII 마스킹·WCAG AA·i18n·라이트/다크 토큰 불변 규칙 반영. PLANNING.md·src 무수정 | diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigController.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigController.java new file mode 100644 index 0000000..5818c50 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigController.java @@ -0,0 +1,45 @@ +package com.zioinfo.kintex.ai; + +import com.zioinfo.kintex.ai.dto.AiConfigDto; +import com.zioinfo.kintex.ai.dto.AiConfigUpdateRequest; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * F100 AI 플랫폼 설정 관리 API(백오피스). 시스템관리자(requireAdmin) 전용. + * + * ★ API 키는 이 API 로 저장/조회하지 않는다(env 전용). + */ +@RestController +@RequestMapping("/api/admin/ai") +public class AiConfigController { + + private final AiConfigService service; + private final SystemAccessGuard guard; + + public AiConfigController(AiConfigService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping("/config") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.getConfig()); + } + + @Audited(action = "AI_CONFIG_UPDATE", targetType = "ai_config") + @PutMapping("/config") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @RequestBody AiConfigUpdateRequest req) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.update(req, principal.userId())); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigMapper.java new file mode 100644 index 0000000..060a8e9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigMapper.java @@ -0,0 +1,50 @@ +package com.zioinfo.kintex.ai; + +import com.zioinfo.kintex.tenant.TenantContext; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * F100 AI 설정({@code ai_config} key/value) 매퍼 — 테넌트 스코프(★표준: PK = (tenant_id, config_key)). + *

★ 보안 불변: 이 테이블에는 API 키를 저장하지 않는다(키는 env 전용). provider/모델/활성화 플래그만 보관. + */ +@Mapper +public interface AiConfigMapper { + + /** 전체 설정 행(config_key, config_val) — 현재 테넌트. */ + @Select("SELECT config_key AS \"key\", config_val AS \"val\" FROM ai_config WHERE tenant_id = #{tenantId}") + List> findAllByTenant(@Param("tenantId") String tenantId); + + default List> findAll() { + return findAllByTenant(TenantContext.currentTenantId()); + } + + /** 단일 값(미설정 시 null) — 현재 테넌트. */ + @Select("SELECT config_val FROM ai_config WHERE tenant_id = #{tenantId} AND config_key = #{key}") + String findValByTenant(@Param("tenantId") String tenantId, @Param("key") String key); + + default String findVal(String key) { + return findValByTenant(TenantContext.currentTenantId(), key); + } + + /** 멱등 upsert — (tenant_id, config_key) 충돌 시 값·수정자·수정시각 갱신. */ + @Insert(""" + INSERT INTO ai_config (tenant_id, config_key, config_val, updated_by, updated_at) + VALUES (#{tenantId}, #{key}, #{val}, #{actor}, now()) + ON CONFLICT (tenant_id, config_key) DO UPDATE + SET config_val = EXCLUDED.config_val, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """) + int upsertByTenant(@Param("tenantId") String tenantId, @Param("key") String key, + @Param("val") String val, @Param("actor") String actor); + + default int upsert(String key, String val, String actor) { + return upsertByTenant(TenantContext.currentTenantId(), key, val, actor); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigService.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigService.java new file mode 100644 index 0000000..5c7ad9f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigService.java @@ -0,0 +1,158 @@ +package com.zioinfo.kintex.ai; + +import com.zioinfo.kintex.ai.dto.AiConfigDto; +import com.zioinfo.kintex.ai.dto.AiConfigUpdateRequest; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * F100 AI provider 런타임 설정({@code ai_config}) 단일 출처. DB 우선 → 미설정 시 기본값 폴백. + * + *

보안: 이 서비스는 Claude API 키를 다루지 않는다. {@link #claudeKeySet()} 은 + * {@link ClaudeTextClient#isConfigured()}(env 존재 여부)만 반환 — 값/길이/마스킹 일절 미포함. + * 설정 테이블에도 키는 저장하지 않는다(provider/모델/활성화 플래그만). + */ +@Service +public class AiConfigService { + + public static final String K_PROVIDER = "ai.provider"; // ENUM: claude|ollama + public static final String K_CLAUDE_MODEL = "ai.claude.model"; // ENUM: 화이트리스트 + public static final String K_OLLAMA_MODEL = "ai.ollama.model"; // STRING + public static final String K_ENABLED = "ai.enabled"; // BOOL + + public static final String PROVIDER_CLAUDE = "claude"; + public static final String PROVIDER_OLLAMA = "ollama"; + public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; + public static final String DEFAULT_OLLAMA_MODEL = "qwen3:1.7b"; + + public static final Set ALLOWED_PROVIDERS = Set.of(PROVIDER_CLAUDE, PROVIDER_OLLAMA); + /** 허용 Claude 모델 ID(임의 문자열 거부). */ + public static final Set ALLOWED_CLAUDE_MODELS = + Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8"); + + private final AiConfigMapper mapper; + private final ClaudeTextClient claudeClient; + private final OllamaTextClient ollamaClient; + + public AiConfigService(AiConfigMapper mapper, ClaudeTextClient claudeClient, OllamaTextClient ollamaClient) { + this.mapper = mapper; + this.claudeClient = claudeClient; + this.ollamaClient = ollamaClient; + } + + // ── 효과값(DB 우선 → 기본) ── + + /** 효과 provider("claude"|"ollama"). 기본 claude(키 미설정이면 라우터가 Ollama 폴백). */ + public String provider() { + String v = dbVal(K_PROVIDER); + if (v != null) { + String p = v.trim().toLowerCase(); + if (ALLOWED_PROVIDERS.contains(p)) { + return p; + } + } + return PROVIDER_CLAUDE; + } + + /** 효과 Claude 모델(DB 우선, 미설정/비허용 시 기본). */ + public String claudeModel() { + String v = dbVal(K_CLAUDE_MODEL); + if (v != null && ALLOWED_CLAUDE_MODELS.contains(v.trim())) { + return v.trim(); + } + return DEFAULT_CLAUDE_MODEL; + } + + /** 효과 Ollama 모델 태그(DB 우선, 미설정 시 기본 소형). */ + public String ollamaModel() { + String v = dbVal(K_OLLAMA_MODEL); + return (v != null && !v.isBlank()) ? v.trim() : DEFAULT_OLLAMA_MODEL; + } + + /** 전역 AI 토글(기본 true). false 면 모든 AI 결과는 degraded → 호출자 폴백. */ + public boolean aiEnabled() { + String v = dbVal(K_ENABLED); + return v == null || !"false".equalsIgnoreCase(v.trim()); + } + + /** Claude API 키가 env 로 설정되어 있는지(여부만). */ + public boolean claudeKeySet() { + return claudeClient.isConfigured(); + } + + /** Ollama 폴백 URL 이 env 로 설정되어 있는지(여부만). */ + public boolean ollamaConfigured() { + return ollamaClient.isConfigured(); + } + + /** 실제 호출이 Claude 경로로 가야 하는지: provider=claude · 키 설정 · AI 활성. */ + public boolean isClaudeActive() { + return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled(); + } + + // ── ADMIN 조회/갱신 ── + + public AiConfigDto getConfig() { + return new AiConfigDto(provider(), claudeModel(), ollamaModel(), + aiEnabled(), claudeKeySet(), ollamaConfigured()); + } + + /** + * 설정 갱신(upsert). 화이트리스트 검증: provider∈{claude,ollama}, claudeModel∈허용셋. + * 각 필드 선택(null/공백 = 기존 유지). 재기동 없이 다음 호출부터 반영. + */ + public AiConfigDto update(AiConfigUpdateRequest req, String actor) { + String act = (actor == null || actor.isBlank()) ? "SYSTEM" : actor; + if (req.provider() != null && !req.provider().isBlank()) { + String p = req.provider().trim().toLowerCase(); + if (!ALLOWED_PROVIDERS.contains(p)) { + throw new ApiException(ErrorCode.VALIDATION, "provider 는 claude 또는 ollama 여야 합니다."); + } + mapper.upsert(K_PROVIDER, p, act); + } + if (req.claudeModel() != null && !req.claudeModel().isBlank()) { + String m = req.claudeModel().trim(); + if (!ALLOWED_CLAUDE_MODELS.contains(m)) { + throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 Claude 모델 ID 입니다."); + } + mapper.upsert(K_CLAUDE_MODEL, m, act); + } + if (req.ollamaModel() != null && !req.ollamaModel().isBlank()) { + mapper.upsert(K_OLLAMA_MODEL, req.ollamaModel().trim(), act); + } + if (req.aiEnabled() != null) { + mapper.upsert(K_ENABLED, req.aiEnabled() ? "true" : "false", act); + } + return getConfig(); + } + + // ── helpers ── + + /** DB 값 로드(전체 조회 후 필터 — 행 수 소량). 공백/null 은 미설정으로 취급. */ + private String dbVal(String key) { + Map all = allValues(); + String v = all.get(key); + return (v != null && !v.isBlank()) ? v : null; + } + + private Map allValues() { + Map m = new HashMap<>(); + List> rows = mapper.findAll(); + if (rows != null) { + for (Map r : rows) { + Object k = r.get("key"); + Object v = r.get("val"); + if (k != null) { + m.put(String.valueOf(k), v == null ? null : String.valueOf(v)); + } + } + } + return m; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiTextRouter.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiTextRouter.java new file mode 100644 index 0000000..faa6fa7 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiTextRouter.java @@ -0,0 +1,84 @@ +package com.zioinfo.kintex.ai; + +import com.zioinfo.kintex.ai.TextAiClient.GenResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * F100 AI provider 선택·폴백 라우터. 모든 AI 텍스트 생성의 단일 진입점. + * + *

선택/폴백 정책: + *

+ * + *

환각 방지: 반환 {@link AiResult} 는 실제 응답 provider 와 degraded 를 정직하게 담는다 — + * 호출자는 이를 근거로 AI 산출물 라벨링(AiLabel)·폴백을 결정한다. + */ +@Service +public class AiTextRouter { + + private static final Logger log = LoggerFactory.getLogger(AiTextRouter.class); + + private final AiConfigService config; + private final ClaudeTextClient claudeClient; + private final OllamaTextClient ollamaClient; + + public AiTextRouter(AiConfigService config, ClaudeTextClient claudeClient, OllamaTextClient ollamaClient) { + this.config = config; + this.claudeClient = claudeClient; + this.ollamaClient = ollamaClient; + } + + /** + * AI 텍스트 생성 결과. + * + * @param text 생성 텍스트(degraded 시 null) + * @param provider 실제 응답 provider("claude"|"ollama"|"none") + * @param degraded 폴백/실패 여부(true 면 호출자가 규칙기반/오류로 처리) + */ + public record AiResult(String text, String provider, boolean degraded) { + + public boolean usable() { + return !degraded && text != null && !text.isBlank(); + } + + static AiResult none() { + return new AiResult(null, "none", true); + } + } + + /** 기본 max_tokens 로 생성. */ + public AiResult generate(String prompt) { + return generate(prompt, 0); + } + + /** max_tokens 상한을 지정해 생성(장문 1회 호출). Claude 경로에만 상한 반영, Ollama 는 기본 옵션. */ + public AiResult generate(String prompt, int maxTokens) { + if (prompt == null || prompt.isBlank() || !config.aiEnabled()) { + return AiResult.none(); + } + if (config.isClaudeActive()) { + GenResult r = maxTokens > 0 + ? claudeClient.generate(prompt, config.claudeModel(), maxTokens) + : claudeClient.generate(prompt, config.claudeModel()); + if (r.usable()) { + return new AiResult(r.text(), AiConfigService.PROVIDER_CLAUDE, false); + } + log.warn("Claude path degraded -> Ollama fallback"); + GenResult f = ollamaClient.generate(prompt, config.ollamaModel()); + return f.usable() + ? new AiResult(f.text(), AiConfigService.PROVIDER_OLLAMA, false) + : AiResult.none(); + } + // provider=ollama, 또는 claude 인데 키 미설정 → Ollama 경로. + GenResult o = ollamaClient.generate(prompt, config.ollamaModel()); + return o.usable() + ? new AiResult(o.text(), AiConfigService.PROVIDER_OLLAMA, false) + : AiResult.none(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/ClaudeTextClient.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/ClaudeTextClient.java new file mode 100644 index 0000000..3f31fed --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/ClaudeTextClient.java @@ -0,0 +1,151 @@ +package com.zioinfo.kintex.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * 외부 Claude(Anthropic) Messages API 텍스트 생성 클라이언트 (F100). + * + *

외부 호출 예외 허용: 소유자 승인(2026-07-03)에 따라 {@code api.anthropic.com} 은 허용된 유일한 + * 외부 경로다. 그 외 외부 API 는 여전히 금지. + * + *

API 키 보안(최우선): 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며 + * ({@code @Value("${ANTHROPIC_API_KEY:}")}), DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다. + * 키는 오직 HTTP 헤더 {@code x-api-key} 로만 전달된다(로그는 상태코드/예외 클래스명만). + * + *

실패 처리: 키 미설정/타임아웃/비200/예외 시 {@code GenResult.fail()} 반환(예외 미투척 → + * 호출자 Ollama 폴백). 신규 라이브러리 0 — JDK {@link HttpClient} 사용. + */ +@Service +public class ClaudeTextClient implements TextAiClient { + + private static final Logger log = LoggerFactory.getLogger(ClaudeTextClient.class); + + /** Anthropic Messages API 엔드포인트(외부 호출 예외 허용 단일 경로). */ + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + static final String DEFAULT_MODEL = "claude-sonnet-4-6"; + private static final int DEFAULT_MAX_TOKENS = 1024; + private static final int TIMEOUT_SEC = 120; + private static final int CONNECT_TIMEOUT_SEC = 15; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */ + @Value("${ANTHROPIC_API_KEY:}") + private String apiKey; + + /** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */ + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + @Override + public GenResult generate(String prompt) { + return generate(prompt, DEFAULT_MODEL, DEFAULT_MAX_TOKENS); + } + + public GenResult generate(String prompt, String model) { + return generate(prompt, model, DEFAULT_MAX_TOKENS); + } + + /** 지정 모델로 생성. 실패 시 degraded(키/본문 미로그 — 상태코드/예외 클래스명만). */ + public GenResult generate(String prompt, String model, int maxTokens) { + if (!isConfigured() || prompt == null || prompt.isBlank()) { + return GenResult.fail(); + } + String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim(); + int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS; + try { + String payload = "{" + + "\"model\":" + str(useModel) + "," + + "\"max_tokens\":" + tokens + "," + + "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]" + + "}"; + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC)) + .build(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .timeout(Duration.ofSeconds(TIMEOUT_SEC)) + .header("content-type", "application/json") + .header("x-api-key", apiKey) // 키는 헤더로만 — 로그/응답 미노출 + .header("anthropic-version", ANTHROPIC_VERSION) + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록 + return GenResult.fail(); + } + String text = extractText(resp.body()); + return (text == null || text.isBlank()) ? GenResult.fail() : GenResult.ok(text.trim()); + } catch (Exception e) { + log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지·키 미기록 + return GenResult.fail(); + } + } + + /** Messages API 응답에서 content[].text(type=text) 추출·연결. */ + private static String extractText(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + JsonNode content = MAPPER.readTree(body).get("content"); + if (content == null || !content.isArray()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (JsonNode block : content) { + JsonNode type = block.get("type"); + if (type != null && "text".equals(type.asText())) { + JsonNode t = block.get("text"); + if (t != null && !t.isNull()) { + sb.append(t.asText()); + } + } + } + String out = sb.toString(); + return out.isBlank() ? null : out; + } catch (Exception e) { + return null; + } + } + + /** JSON 문자열 이스케이프. */ + private static String str(String s) { + if (s == null) { + return "\"\""; + } + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.append("\"").toString(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/OllamaTextClient.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/OllamaTextClient.java new file mode 100644 index 0000000..21b0e48 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/OllamaTextClient.java @@ -0,0 +1,131 @@ +package com.zioinfo.kintex.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * 온프레미스 Ollama 텍스트 생성 클라이언트 (F100 폴백 경로). + * + *

설정: 베이스 URL 은 환경변수 {@code OLLAMA_URL} 에서만 로드한다({@code @Value("${OLLAMA_URL:}")}). + * 미설정 시 폴백을 스킵({@link #isConfigured()}=false → 항상 degraded)한다 — 개발 서버 RAM 제약상 + * Ollama 미기동 환경에서 조용히 degraded 로 흐르게 한다. + * + *

실패 처리: 비활성/busy/비200/타임아웃/예외 시 {@code GenResult.fail()}(예외 미투척). + * 외부 API 금지 — {@code OLLAMA_URL}(온프레미스) 만 호출한다. 동시성 1(Semaphore) — 콜드로드 RAM 보호. + */ +@Service +public class OllamaTextClient implements TextAiClient { + + private static final Logger log = LoggerFactory.getLogger(OllamaTextClient.class); + private static final Semaphore GATE = new Semaphore(1); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int TIMEOUT_SEC = 120; + + /** 온프레미스 Ollama 베이스 URL(env 전용). 미설정 시 폴백 스킵. */ + @Value("${OLLAMA_URL:}") + private String baseUrl; + + /** Ollama 폴백이 구성되어 있는지(URL 설정 여부). */ + public boolean isConfigured() { + return baseUrl != null && !baseUrl.isBlank(); + } + + @Override + public GenResult generate(String prompt) { + return generate(prompt, null); + } + + /** 모델을 지정해 생성. modelOverride 공백이면 라우터가 넘긴 기본 모델을 사용해야 한다(여기선 공백 방어). */ + public GenResult generate(String prompt, String model) { + if (!isConfigured() || prompt == null || prompt.isBlank()) { + return GenResult.fail(); + } + String useModel = (model == null || model.isBlank()) ? "qwen3:1.7b" : model.trim(); + boolean acquired = false; + try { + acquired = GATE.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS); + if (!acquired) { + log.warn("Ollama generate busy -> degraded fallback"); + return GenResult.fail(); + } + String payload = "{" + + "\"model\":" + str(useModel) + "," + + "\"prompt\":" + str(prompt) + "," + + "\"stream\":false," + + "\"options\":{\"temperature\":0.2}" + + "}"; + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl.trim().replaceAll("/+$", "") + "/api/generate")) + .timeout(Duration.ofSeconds(TIMEOUT_SEC)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + log.warn("Ollama status {} -> degraded fallback", resp.statusCode()); + return GenResult.fail(); + } + String text = extractResponse(resp.body()); + return (text == null || text.isBlank()) ? GenResult.fail() : GenResult.ok(text.trim()); + } catch (Exception e) { + log.warn("Ollama generate failed -> degraded fallback: {}", e.getClass().getSimpleName()); + return GenResult.fail(); + } finally { + if (acquired) { + GATE.release(); + } + } + } + + private static String extractResponse(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + JsonNode resp = MAPPER.readTree(body).get("response"); + return (resp != null && !resp.isNull()) ? resp.asText() : null; + } catch (Exception e) { + return null; + } + } + + private static String str(String s) { + if (s == null) { + return "\"\""; + } + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.append("\"").toString(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/TextAiClient.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/TextAiClient.java new file mode 100644 index 0000000..7eebe7f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/TextAiClient.java @@ -0,0 +1,32 @@ +package com.zioinfo.kintex.ai; + +/** + * 텍스트 생성 provider 추상화 (F100 AI 플랫폼 라우터). + * + *

온프레미스 Ollama({@link OllamaTextClient})와 외부 Claude({@link ClaudeTextClient})를 동일 계약으로 다룬다. + * 구현체는 어떤 사유로든(비활성/실패/타임아웃/키·URL 미설정) 실패 시 {@link GenResult#degraded()}=true· + * {@link GenResult#text()}=null 을 반환하고 예외를 던지지 않는다. 폴백은 호출자({@link AiTextRouter})가 책임진다. + * + *

provider 선택/폴백 라우팅은 {@link AiTextRouter} 가 담당한다. + */ +public interface TextAiClient { + + /** 프롬프트로 텍스트를 생성한다. 실패 시 {@code degraded=true·text=null}(예외 미투척). */ + GenResult generate(String prompt); + + /** 생성 결과: 텍스트(폴백 시 null) + degraded(폴백 여부). */ + record GenResult(String text, boolean degraded) { + + public static GenResult fail() { + return new GenResult(null, true); + } + + public static GenResult ok(String text) { + return new GenResult(text, false); + } + + public boolean usable() { + return !degraded && text != null && !text.isBlank(); + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigDto.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigDto.java new file mode 100644 index 0000000..5e250fa --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigDto.java @@ -0,0 +1,20 @@ +package com.zioinfo.kintex.ai.dto; + +/** + * F100 AI 설정 조회 응답. 키 값·길이·마스킹 일절 미포함 — {@code claudeKeySet} 불리언(env 존재 여부)만. + * + * @param provider 효과 provider("claude"|"ollama") + * @param claudeModel 효과 Claude 모델 ID + * @param ollamaModel 효과 Ollama 모델 태그 + * @param aiEnabled 전역 AI 활성 여부 + * @param claudeKeySet Claude API 키가 서버 env 로 설정되어 있는지(여부만) + * @param ollamaConfigured Ollama 폴백 URL 이 env 로 설정되어 있는지(여부만) + */ +public record AiConfigDto( + String provider, + String claudeModel, + String ollamaModel, + boolean aiEnabled, + boolean claudeKeySet, + boolean ollamaConfigured) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigUpdateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigUpdateRequest.java new file mode 100644 index 0000000..d0d504d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigUpdateRequest.java @@ -0,0 +1,17 @@ +package com.zioinfo.kintex.ai.dto; + +/** + * F100 AI 설정 갱신 요청. API 키는 받지 않는다(키는 env 전용) — provider/모델/활성화만. + * 모든 필드 선택(null/공백 = 기존 유지). 검증(화이트리스트)은 서비스가 수행. + * + * @param provider "claude" 또는 "ollama" + * @param claudeModel 허용 Claude 모델 ID + * @param ollamaModel Ollama 모델 태그 + * @param aiEnabled 전역 AI 활성 토글 + */ +public record AiConfigUpdateRequest( + String provider, + String claudeModel, + String ollamaModel, + Boolean aiEnabled) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryController.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryController.java new file mode 100644 index 0000000..dd0848a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryController.java @@ -0,0 +1,57 @@ +package com.zioinfo.kintex.ai.nlquery; + +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.NlQueryRequest; +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.NlQueryResult; +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RulesChatRequest; +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RulesChatResult; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * F092 자연어 조회(Text-to-SQL) + F025 규정 자연어 챗봇 API (AI 라우터 경유). + * + *

+ * 두 엔드포인트 모두 산출 출처(provider·degraded)와 근거(생성 SQL / 인용 룰)를 응답에 정직하게 담는다. + */ +@RestController +@RequestMapping("/api/ai") +public class NlQueryController { + + private final NlQueryService nlQueryService; + private final RulesChatService rulesChatService; + private final SystemAccessGuard guard; + + public NlQueryController(NlQueryService nlQueryService, RulesChatService rulesChatService, + SystemAccessGuard guard) { + this.nlQueryService = nlQueryService; + this.rulesChatService = rulesChatService; + this.guard = guard; + } + + /** 자연어 조회 — 시스템관리자(홀매니저/ADMIN) 전용. AI 미가용/가드 위반 시 명확한 4xx. */ + @Audited(action = "AI_NLQUERY", targetType = "nlquery") + @PostMapping("/nlquery") + public ApiResponse nlquery(@AuthenticationPrincipal KintexPrincipal principal, + @RequestBody NlQueryRequest req) { + guard.requireAdmin(principal); + return ApiResponse.ok(nlQueryService.ask(req == null ? null : req.question())); + } + + /** 규정 챗봇 — 인증 사용자면 열람 가능(규정/요율은 정책 안내 성격). */ + @PostMapping("/rules-chat") + public ApiResponse rulesChat(@AuthenticationPrincipal KintexPrincipal principal, + @RequestBody RulesChatRequest req) { + guard.require(principal); + return ApiResponse.ok(rulesChatService.chat(req == null ? null : req.question())); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryReadMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryReadMapper.java new file mode 100644 index 0000000..a324d04 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryReadMapper.java @@ -0,0 +1,21 @@ +package com.zioinfo.kintex.ai.nlquery; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * F092 자연어 조회 전용 read-only 실행 매퍼. {@link NlQuerySqlGuard} 통과 SQL 만 전달받아 실행한다. + * + *

이 매퍼는 오직 SELECT 결과를 {@code LinkedHashMap}(컬럼 순서 보존) 리스트로 반환한다. + * SQL 은 XML {@code ${sql}} 로 그대로 실행되므로, 호출자는 반드시 사전에 가드 검증을 거쳐야 한다. + * 실제 실행은 {@code @Transactional(readOnly=true)} 하에서 이루어진다(이중 방어). + */ +@Mapper +public interface NlQueryReadMapper { + + /** 가드 통과 SELECT 실행. 결과 각 행은 컬럼 순서를 보존한 LinkedHashMap. */ + List> runReadOnly(@Param("sql") String sql); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryService.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryService.java new file mode 100644 index 0000000..b1dff1c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryService.java @@ -0,0 +1,98 @@ +package com.zioinfo.kintex.ai.nlquery; + +import com.zioinfo.kintex.ai.AiTextRouter; +import com.zioinfo.kintex.ai.AiTextRouter.AiResult; +import com.zioinfo.kintex.ai.nlquery.NlQuerySqlGuard.GuardedSql; +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.NlQueryResult; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * F092 자연어 조회(Text-to-SQL) — 자연어 질문을 안전한 read-only SELECT 로 변환·실행한다. + * + *

파이프라인: 질문 + 화이트리스트 스키마 프롬프트 → {@link AiTextRouter}(Claude→Ollama) → SQL 추출 → + * {@link NlQuerySqlGuard} 결정론 검증(단일 SELECT·화이트리스트·PII 차단·LIMIT 200) → + * read-only 트랜잭션 실행 → 결과 + 생성 SQL 근거 반환(투명성). + * + *

보안: 프롬프트 스키마에서 PII 원문 컬럼(name/phone/email)을 아예 제외한다. 가드가 재차 차단한다(이중 방어). + * AI 미가용/가드 위반은 예외를 던져 명확한 4xx(AI_UNAVAILABLE/VALIDATION)로 응답한다(환각·오작동 방지). + */ +@Service +public class NlQueryService { + + private static final Logger log = LoggerFactory.getLogger(NlQueryService.class); + + private final AiTextRouter router; + private final NlQuerySqlGuard guard; + private final NlQueryReadMapper readMapper; + + public NlQueryService(AiTextRouter router, NlQuerySqlGuard guard, NlQueryReadMapper readMapper) { + this.router = router; + this.guard = guard; + this.readMapper = readMapper; + } + + /** 화이트리스트 테이블 스키마(PII 원문 컬럼 제외) — 프롬프트에 주입되는 유일한 스키마 근거. */ + private static final String SCHEMA = """ + -- PostgreSQL. 아래 테이블/컬럼만 조회 가능(그 외 테이블·컬럼 금지). 개인정보(name/phone/email)는 존재하지 않는 것으로 간주하라. + event(id, name, start_date, end_date, status) + hall(id, exhibition_center, label, width_m, depth_m, height_m, area_m2, floor_load_t_per_m2, floor_type, booth_capacity, has_gas) + booth(id, layout_id, booth_no, booth_type, size_w_m, size_d_m, height_m, floor_load_t_per_m2, assigned_company_name, premium) + visitor_registration(id, event_id, company, visitor_type, agree_privacy, agree_marketing, badge_issued, checkin_state, registered_at) + lead(id, event_id, booth_id, role, company, product, interest, score, ai_generated, collected_at) + invoice(id, event_id, exhibitor_name, invoice_no, subtotal, vat, total, paid_amount, status, due_date, issued_at, paid_at) + auction(id, event_id, title, category, auction_type, award_criteria, weight_price, weight_reputation, weight_delivery, round, deadline, materials, created_at) + """; + + private String buildPrompt(String question) { + return "당신은 킨텍스 전시운영 데이터베이스의 SQL 생성기다. 아래 스키마만 근거로 사용자의 질문에 답할 PostgreSQL SELECT 문 하나만 생성하라.\n\n" + + SCHEMA + "\n" + + "규칙(엄수):\n" + + "1) 반드시 단일 SELECT 문. INSERT/UPDATE/DELETE/DDL/세미콜론/주석 금지.\n" + + "2) 위 스키마의 테이블/컬럼만 사용. name/phone/email 등 개인정보 컬럼은 절대 사용하지 마라.\n" + + "3) SELECT * 대신 필요한 컬럼만 명시하고, booth.geom 같은 공간 컬럼은 선택하지 마라.\n" + + "4) 결과는 LIMIT 200 이하로 제한하라.\n" + + "5) 스키마에 없는 정보는 지어내지 말고, 답할 수 없으면 가장 근접한 조회를 생성하라.\n" + + "6) 출력은 SQL 문 하나만. 설명·마크다운·코드펜스 없이 SQL 텍스트만 반환하라.\n\n" + + "질문: " + question; + } + + /** + * 자연어 질문 → 검증된 SELECT 실행. read-only 트랜잭션(쓰기 원천 차단). + * @throws ApiException AI 미가용(AI_UNAVAILABLE·503) 또는 가드 위반(VALIDATION·400) + */ + @Transactional(readOnly = true) + public NlQueryResult ask(String question) { + if (question == null || question.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "질문을 입력해 주세요."); + } + AiResult ai = router.generate(buildPrompt(question), 512); + if (!ai.usable()) { + throw new ApiException(ErrorCode.AI_UNAVAILABLE); + } + // 가드: 위반 시 VALIDATION(400) — 실행 전 차단. + GuardedSql g = guard.validate(ai.text()); + + List> rows; + try { + rows = readMapper.runReadOnly(g.sql()); + } catch (org.springframework.dao.DataAccessException e) { + // 실행 오류(존재하지 않는 컬럼·문법 등)는 스택트레이스 미노출, 요약만. + log.warn("NL query execution failed: {}", e.getClass().getSimpleName()); + throw new ApiException(ErrorCode.VALIDATION, "생성된 조회를 실행할 수 없습니다. 질문을 더 구체적으로 입력해 주세요."); + } + if (rows == null) { + rows = new ArrayList<>(); + } + List columns = rows.isEmpty() ? List.of() : new ArrayList<>(rows.get(0).keySet()); + return new NlQueryResult(question, g.sql(), g.tables(), columns, rows, rows.size(), ai.provider()); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuard.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuard.java new file mode 100644 index 0000000..5d5d487 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuard.java @@ -0,0 +1,153 @@ +package com.zioinfo.kintex.ai.nlquery; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * F092 자연어 조회(Text-to-SQL) 안전 가드 — LLM 이 생성한 SQL 을 실행 전 검증한다. + * + *

LLM 산출 SQL 은 신뢰할 수 없다(프롬프트 인젝션·환각·오작성). 이 가드는 결정론적(정규식 기반) 검증으로 + * 오직 단일 읽기 전용 SELECT 만, 화이트리스트 테이블 에 대해서만, PII 원문 컬럼 없이, + * LIMIT 200 이하 로만 통과시킨다. 하나라도 위반하면 {@link ErrorCode#VALIDATION} 을 던져 실행을 차단한다. + * + *

이중 방어: 여기서 통과해도 실제 실행은 read-only 트랜잭션({@link NlQueryService})에서 수행한다. + */ +@Component +public class NlQuerySqlGuard { + + /** 조회 허용 테이블(그 외 모든 테이블·시스템 카탈로그 차단). */ + public static final Set ALLOWED_TABLES = Set.of( + "event", "hall", "booth", "visitor_registration", "lead", "invoice", "auction"); + + /** PII 원문 컬럼(어떤 경우에도 SQL 에 등장 금지 — 프롬프트에서도 제외). */ + private static final Set PII_COLUMNS = Set.of("phone", "email"); + /** visitor_registration·lead 참조 시 추가로 금지되는 PII 컬럼(event.name 등 비-PII 는 허용). */ + private static final Set PII_NAME_TABLES = Set.of("visitor_registration", "lead"); + + /** 쓰기/DDL/제어/우회 키워드(단어 경계). SELECT 단일 읽기문 외 일체 차단. */ + private static final Pattern FORBIDDEN = Pattern.compile( + "\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|" + + "call|exec|execute|do|copy|vacuum|analyze|reindex|cluster|lock|" + + "comment|into|returning|nextval|setval|pg_sleep|pg_read_file|lo_import|lo_export|dblink)\\b", + Pattern.CASE_INSENSITIVE); + + /** FROM/JOIN 뒤의 테이블 참조(스키마.테이블 형태 포함) 추출. */ + private static final Pattern TABLE_REF = Pattern.compile( + "\\b(?:from|join)\\s+([a-zA-Z_][\\w.]*)", Pattern.CASE_INSENSITIVE); + + private static final Pattern LIMIT_NUM = Pattern.compile("\\blimit\\s+(\\d+)", Pattern.CASE_INSENSITIVE); + private static final int MAX_LIMIT = 200; + + /** 가드 통과 SQL 과 검증 근거. */ + public record GuardedSql(String sql, java.util.List tables, int limit) { + } + + /** + * LLM 산출 SQL 을 검증·정규화한다. 위반 시 {@link ApiException}(VALIDATION) 을 던진다. + * 통과 시 LIMIT 이 강제 부여/제한된 실행용 SQL 을 반환한다. + */ + public GuardedSql validate(String raw) { + if (raw == null || raw.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "생성된 SQL 이 비어 있습니다."); + } + String sql = strip(raw); + String lower = sql.toLowerCase(Locale.ROOT); + + // 1) 주석 차단(주석 기반 우회 방지). + if (sql.contains("--") || sql.contains("/*") || sql.contains("*/") || sql.contains("#")) { + throw new ApiException(ErrorCode.VALIDATION, "SQL 주석은 허용되지 않습니다."); + } + // 2) 다중문 차단(세미콜론). 끝의 단일 세미콜론만 허용해 제거. + String noTrailing = sql.endsWith(";") ? sql.substring(0, sql.length() - 1) : sql; + if (noTrailing.contains(";")) { + throw new ApiException(ErrorCode.VALIDATION, "단일 SELECT 문만 허용됩니다(다중 문 금지)."); + } + sql = noTrailing.trim(); + lower = sql.toLowerCase(Locale.ROOT); + + // 3) SELECT 로만 시작(단일 읽기문). WITH/CTE·서브쿼리 밖 시작 키워드 제한. + if (!lower.startsWith("select")) { + throw new ApiException(ErrorCode.VALIDATION, "SELECT 로 시작하는 조회문만 허용됩니다."); + } + // 4) 금지 키워드(쓰기/DDL/제어/우회) 차단. + Matcher fm = FORBIDDEN.matcher(sql); + if (fm.find()) { + throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 키워드가 포함되어 있습니다: " + fm.group(1)); + } + // 5) 테이블 화이트리스트 검증(FROM/JOIN 대상 전부 허용 목록에 있어야 함). + java.util.List tables = new java.util.ArrayList<>(); + Set refSet = new LinkedHashSet<>(); + Matcher tm = TABLE_REF.matcher(sql); + while (tm.find()) { + String ref = tm.group(1); + // 스키마 접두어 제거(public.event → event) 및 소문자화. + String tbl = ref.contains(".") ? ref.substring(ref.lastIndexOf('.') + 1) : ref; + tbl = tbl.toLowerCase(Locale.ROOT); + if (!ALLOWED_TABLES.contains(tbl)) { + throw new ApiException(ErrorCode.VALIDATION, "조회 허용되지 않은 테이블입니다: " + tbl); + } + if (refSet.add(tbl)) { + tables.add(tbl); + } + } + if (tables.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION, "조회 대상 테이블(FROM)을 찾을 수 없습니다."); + } + // 6) PII 원문 컬럼 차단. phone/email 은 항상, name 은 visitor_registration/lead 참조 시 차단. + for (String pii : PII_COLUMNS) { + if (containsWord(lower, pii)) { + throw new ApiException(ErrorCode.VALIDATION, "개인정보 컬럼은 조회할 수 없습니다."); + } + } + boolean touchesPiiTable = tables.stream().anyMatch(PII_NAME_TABLES::contains); + if (touchesPiiTable && containsWord(lower, "name")) { + throw new ApiException(ErrorCode.VALIDATION, "개인정보 컬럼(name)은 조회할 수 없습니다."); + } + // 7) LIMIT 200 강제. 기존 limit 은 200 이하만 허용, 없으면 부여. + int limit; + Matcher lm = LIMIT_NUM.matcher(sql); + if (lm.find()) { + try { + limit = Integer.parseInt(lm.group(1)); + } catch (NumberFormatException e) { + throw new ApiException(ErrorCode.VALIDATION, "LIMIT 값이 올바르지 않습니다."); + } + if (limit > MAX_LIMIT || limit <= 0) { + throw new ApiException(ErrorCode.VALIDATION, "LIMIT 는 1~" + MAX_LIMIT + " 사이여야 합니다."); + } + } else { + sql = sql + " LIMIT " + MAX_LIMIT; + limit = MAX_LIMIT; + } + return new GuardedSql(sql, tables, limit); + } + + /** 코드펜스(```sql ... ```)·잉여 공백 제거. */ + private static String strip(String s) { + String out = s.trim(); + if (out.startsWith("```")) { + int nl = out.indexOf('\n'); + if (nl >= 0) { + out = out.substring(nl + 1); + } + int fence = out.lastIndexOf("```"); + if (fence >= 0) { + out = out.substring(0, fence); + } + } + // 여러 줄/탭을 단일 공백으로 정규화(가드 정규식 안정화). + return out.replaceAll("\\s+", " ").trim(); + } + + /** 단어 경계 기반 토큰 포함 검사(부분 문자열 오탐 방지). */ + private static boolean containsWord(String haystackLower, String word) { + return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(haystackLower).find(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/RulesChatService.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/RulesChatService.java new file mode 100644 index 0000000..109440a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/RulesChatService.java @@ -0,0 +1,222 @@ +package com.zioinfo.kintex.ai.nlquery; + +import com.zioinfo.kintex.ai.AiTextRouter; +import com.zioinfo.kintex.ai.AiTextRouter.AiResult; +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RuleCitation; +import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RulesChatResult; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.rules.ComplianceRuleSet; +import com.zioinfo.kintex.rules.RateRuleSet; +import com.zioinfo.kintex.rules.RuleSetLoader; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * F025 규정 자연어 챗봇 — 룰셋(규정·요율 JSON)을 코퍼스로 근거 기반 답변한다. + * + *

파이프라인: 질문 → 키워드 매칭으로 관련 룰 항목 선별(근거) → {@link AiTextRouter}(Claude→Ollama)로 + * 선별된 룰만 근거로 답변 생성(룰 code/값 인용 필수) → 응답에 인용 룰 동봉. + * + *

환각 방지(핵심): + *

    + *
  • 관련 룰을 하나도 못 찾으면 → AI 호출 없이 "규정에서 찾지 못했습니다" 정직 응답(grounded=false).
  • + *
  • 룰은 찾았으나 AI 미가용 → AI 지어내기 대신 찾은 룰 항목 자체를 요약해 반환(degraded=true, provider="rules").
  • + *
  • 프롬프트에 "제공된 규정 항목 밖의 내용은 지어내지 마라"를 명시.
  • + *
+ */ +@Service +public class RulesChatService { + + private final RuleSetLoader loader; + private final AiTextRouter router; + + public RulesChatService(RuleSetLoader loader, AiTextRouter router) { + this.loader = loader; + this.router = router; + } + + /** 검색용 룰 항목(내부). */ + private record Item(String code, String label, String detail, String haystack) { + RuleCitation toCitation() { + return new RuleCitation(code, label, detail); + } + } + + public RulesChatResult chat(String question) { + if (question == null || question.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "질문을 입력해 주세요."); + } + List corpus = buildCorpus(); + List matched = match(question, corpus, 6); + + // 근거 없음 → AI 호출 없이 정직 응답(환각 차단). + if (matched.isEmpty()) { + return new RulesChatResult(question, + "규정에서 관련 내용을 찾지 못했습니다. 높이·리깅·방염·바닥하중·통로·임대료·전기/네트워크 등 규정/요율 키워드로 다시 질문해 주세요.", + List.of(), false, "none", true); + } + + List citations = matched.stream().map(Item::toCitation).toList(); + AiResult ai = router.generate(buildPrompt(question, matched), 700); + + if (ai.usable()) { + return new RulesChatResult(question, ai.text().trim(), citations, true, ai.provider(), false); + } + // AI 미가용 → 지어내지 말고 찾은 룰 항목을 그대로 요약(근거 기반·정직). + StringBuilder sb = new StringBuilder("관련 규정 항목입니다(AI 요약 미가용 — 규정 원문 기준):\n"); + for (Item it : matched) { + sb.append("· [").append(it.code()).append("] ").append(it.label()); + if (it.detail() != null && !it.detail().isBlank()) { + sb.append(" — ").append(it.detail()); + } + sb.append('\n'); + } + return new RulesChatResult(question, sb.toString().trim(), citations, true, "rules", true); + } + + private String buildPrompt(String question, List matched) { + StringBuilder rules = new StringBuilder(); + for (Item it : matched) { + rules.append("- code=").append(it.code()) + .append(" | ").append(it.label()); + if (it.detail() != null && !it.detail().isBlank()) { + rules.append(" | ").append(it.detail()); + } + rules.append('\n'); + } + String version = loader.compliance() != null ? loader.compliance().rulesetVersion() : "compliance"; + return "당신은 킨텍스 전시 규정·요율 안내 도우미다. 아래 '규정 항목'만 근거로 사용자 질문에 한국어로 간결히 답하라.\n\n" + + "규정 항목(룰셋 " + version + "):\n" + rules + "\n" + + "규칙(엄수):\n" + + "1) 위 규정 항목에 있는 내용만 사용하라. 항목 밖의 수치·규정을 지어내지 마라.\n" + + "2) 답변에 근거가 된 규정 code 를 대괄호로 인용하라(예: [HEIGHT_MAX]).\n" + + "3) 제공된 항목으로 답할 수 없으면 '제공된 규정에서 확인되지 않습니다'라고 답하라.\n" + + "4) 규정은 사전 안내이며 최종 승인은 킨텍스·구조기술사 판단임을 필요 시 덧붙여라.\n\n" + + "질문: " + question; + } + + // ── 코퍼스 구성 ── + + private List buildCorpus() { + List items = new ArrayList<>(); + ComplianceRuleSet c = loader.compliance(); + if (c != null && c.rules() != null) { + for (ComplianceRuleSet.Rule r : c.rules()) { + String detail = complianceDetail(r); + String hay = norm(String.join(" ", + safe(r.code()), safe(r.label()), safe(r.group()), safe(r.metric()), safe(r.unit()), + safe(r.severity()), detail)); + items.add(new Item(safe(r.code()), safe(r.label()), detail, hay)); + } + } + RateRuleSet rates = loader.rates(); + if (rates != null) { + addRate(items, rates.rental(), "RATE_RENTAL", "임대료·전시장 임대 요율 임대 요금 rental 전시장 로비 옥외 이벤트홀 성수기 할증 보증금 계약금"); + addRate(items, rates.utility(), "RATE_UTILITY", "유틸리티 전기 전력 kw 네트워크 인터넷 급수 배수 급배수 배관 압축공기 요금 요율 utility power network"); + addRate(items, rates.boothStandard(), "RATE_BOOTH", "기본부스 조립부스 프리미엄 부스 표준 사양 스포트라이트 콘센트 전력"); + } + return items; + } + + private String complianceDetail(ComplianceRuleSet.Rule r) { + StringBuilder d = new StringBuilder(); + if (r.operator() != null) { + d.append(r.operator()); + } + if (r.threshold() != null) { + d.append(' ').append(trimNum(r.threshold())); + } + if (r.min() != null && r.max() != null) { + d.append(' ').append(trimNum(r.min())).append('~').append(trimNum(r.max())); + } + if (r.unit() != null) { + d.append(r.unit()); + } + if (r.hallLimits() != null && !r.hallLimits().isEmpty()) { + d.append(" 홀별한도=").append(r.hallLimits()); + } + if (r.forbidden() != null && !r.forbidden().isEmpty()) { + d.append(" 금지=").append(r.forbidden()); + } + if (r.requiresDocument() != null) { + d.append(" 필요서류=").append(r.requiresDocument()); + } + if (r.severity() != null) { + d.append(" (").append("block".equals(r.severity()) ? "차단" : "경고").append(")"); + } + return d.toString().trim(); + } + + private void addRate(List items, Map map, String code, String keywords) { + if (map == null || map.isEmpty()) { + return; + } + String detail = flatten(map); + String hay = norm(code + " " + keywords + " " + detail); + items.add(new Item(code, rateLabel(code), detail, hay)); + } + + private static String rateLabel(String code) { + return switch (code) { + case "RATE_RENTAL" -> "임대 요율(전시장/로비/옥외/이벤트홀·성수기 할증·보증금)"; + case "RATE_UTILITY" -> "유틸리티 요율(전기·네트워크·급배수·압축공기)"; + case "RATE_BOOTH" -> "기본/프리미엄 부스 표준 사양"; + default -> code; + }; + } + + private static String flatten(Map map) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : map.entrySet()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(e.getKey()).append('=').append(e.getValue()); + } + return sb.toString(); + } + + // ── 키워드 매칭 ── + + /** 질문 토큰(길이≥2)이 항목 haystack 에 등장하는 개수로 스코어링, 상위 N 반환(스코어>0). */ + private List match(String question, List corpus, int topN) { + String[] tokens = norm(question).split("\\s+"); + record Scored(Item item, int score) { + } + List scored = new ArrayList<>(); + for (Item it : corpus) { + int s = 0; + for (String tok : tokens) { + if (tok.length() >= 2 && it.haystack().contains(tok)) { + s++; + } + } + if (s > 0) { + scored.add(new Scored(it, s)); + } + } + scored.sort((a, b) -> Integer.compare(b.score(), a.score())); + List out = new ArrayList<>(); + for (int i = 0; i < Math.min(topN, scored.size()); i++) { + out.add(scored.get(i).item()); + } + return out; + } + + private static String norm(String s) { + return s == null ? "" : s.toLowerCase(Locale.ROOT); + } + + private static String safe(String s) { + return s == null ? "" : s; + } + + private static String trimNum(double d) { + return d == Math.floor(d) ? String.valueOf((long) d) : String.valueOf(d); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/dto/NlQueryDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/dto/NlQueryDtos.java new file mode 100644 index 0000000..5584842 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/dto/NlQueryDtos.java @@ -0,0 +1,66 @@ +package com.zioinfo.kintex.ai.nlquery.dto; + +import java.util.List; +import java.util.Map; + +/** + * F092 자연어 조회 / F025 규정 챗봇 요청·응답 DTO 모음. + * 응답은 항상 산출 출처(provider·degraded)와 근거(생성 SQL / 인용 룰)를 정직하게 담는다(투명성·환각 방지). + */ +public final class NlQueryDtos { + + private NlQueryDtos() { + } + + /** 자연어 조회 요청. */ + public record NlQueryRequest(String question) { + } + + /** + * 자연어 조회 결과. + * + * @param question 사용자 질문(에코) + * @param sql 실제 실행된(가드 통과) SQL — 투명성 위해 반드시 노출 + * @param tables 참조 테이블(가드 검증 근거) + * @param columns 결과 컬럼 순서 + * @param rows 결과 행(각 행 컬럼→값) + * @param rowCount 행 수 + * @param provider AI provider("claude"|"ollama") + */ + public record NlQueryResult( + String question, + String sql, + List tables, + List columns, + List> rows, + int rowCount, + String provider) { + } + + /** 규정 챗봇 요청. */ + public record RulesChatRequest(String question) { + } + + /** 인용 룰 항목(근거). */ + public record RuleCitation(String code, String label, String detail) { + } + + /** + * 규정 챗봇 응답. + * + * @param question 사용자 질문(에코) + * @param answer 답변(근거 룰 인용 필수 · 근거 없으면 정직 응답) + * @param citations 근거 룰 항목(키/값) + * @param grounded 근거 룰을 실제로 찾았는지(false 면 "규정에서 찾지 못함") + * @param provider 응답 출처("claude"|"ollama"|"rules"|"none") + * @param degraded AI 미가용으로 규칙 기반 요약으로 강등되었는지 + */ + public record RulesChatResult( + String question, + String answer, + List citations, + boolean grounded, + String provider, + boolean degraded) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java index a21caa9..3bcf12c 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java @@ -20,10 +20,12 @@ import java.util.List; public class AuctionController { private final AuctionService service; + private final BoqService boqService; private final EventAccessGuard guard; - public AuctionController(AuctionService service, EventAccessGuard guard) { + public AuctionController(AuctionService service, BoqService boqService, EventAccessGuard guard) { this.service = service; + this.boqService = boqService; this.guard = guard; } @@ -54,6 +56,18 @@ public class AuctionController { return ApiResponse.ok(service.getDetail(id, principal)); } + /** + * GET /api/auctions/{id}/boq — 물량서(BOQ) 자동산출(자료 패키지 첨부). + * 부스 설계 데이터(M2/M4)에서 규칙 기반 집계. 옥션 상세 열람 권한과 동일(인증 + 옥션 존재). + */ + @GetMapping("/{id}/boq") + public ApiResponse boq( + @AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(boqService.boq(id)); + } + /** POST /api/auctions/{id}/bids — 응찰(견적서 제출). 등록업체만·마감 후 409. */ @Audited(action = "AUCTION_BID", targetType = "bid") @PostMapping("/{id}/bids") diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java index 55b9343..91b5e82 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java @@ -140,6 +140,7 @@ public interface AuctionMapper { @Select(""" SELECT aw.id, aw.bid_id AS "bidId", aw.reason, c.name AS "companyName", + b.total AS "total", to_char(aw.awarded_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "awardedAt" FROM award aw JOIN bid b ON b.id = aw.bid_id @@ -229,4 +230,34 @@ public interface AuctionMapper { ORDER BY aw.awarded_at DESC """) List> listMyAwardedBooths(@Param("companyId") String companyId); + + // ── F035 BOQ 물량 집계(M2 booth/layout·M4 utility_order — 읽기 전용) ────────── + /** + * 부스 폴리곤 면적·둘레·부스수·최대높이 집계. boothNo 가 null 이면 행사 전체 부스, 있으면 해당 부스코드만. + * SRID 0 = 홀 로컬 평면(미터) → ST_Area/ST_Perimeter 단위 = ㎡/m(§V3). + */ + @Select(""" + SELECT COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2", + COALESCE(sum(ST_Perimeter(b.geom)), 0) AS "perimeterM", + count(b.id) AS "boothCount", + COALESCE(max(b.height_m), 0) AS "maxHeightM" + FROM booth b + JOIN layout l ON l.id = b.layout_id + WHERE l.event_id = #{eventId} + AND (#{boothNo}::text IS NULL OR b.booth_no = #{boothNo}) + """) + Map boqBoothAggregate(@Param("eventId") String eventId, + @Param("boothNo") String boothNo); + + /** 유틸리티 신청 건수(M4). boothNo 필터 동일. */ + @Select(""" + SELECT count(*)::int AS "orderCount" + FROM utility_order uo + JOIN booth b ON b.id = uo.booth_id + JOIN layout l ON l.id = b.layout_id + WHERE l.event_id = #{eventId} + AND (#{boothNo}::text IS NULL OR b.booth_no = #{boothNo}) + """) + Map boqUtilityAggregate(@Param("eventId") String eventId, + @Param("boothNo") String boothNo); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java index c3bff62..2b65143 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java @@ -31,9 +31,12 @@ public class AuctionService { private static final int MAX_LIMIT = 500; private final AuctionMapper mapper; + private final com.zioinfo.kintex.settlement.SettlementService settlementService; - public AuctionService(AuctionMapper mapper) { + public AuctionService(AuctionMapper mapper, + com.zioinfo.kintex.settlement.SettlementService settlementService) { this.mapper = mapper; + this.settlementService = settlementService; } // ── 목록(SCR-26) ────────────────────────────────────────────────────────── @@ -238,6 +241,16 @@ public class AuctionService { p.put("awardedBy", principal.userId()); mapper.insertAward(p); Map aw = mapper.findAward(id); + + // F084 옥션 수수료 → 정산 청구 자동 생성(멱등 — 같은 낙찰 중복 생성 없음). 실패해도 낙찰은 유효. + try { + settlementService.createAuctionFeeInvoice( + str(a.get("eventId")), awardId, id, str(a.get("title")), + str(aw.get("companyName")), lngPrim(aw.get("total")), principal); + } catch (RuntimeException e) { + // 수수료 청구 생성 실패는 낙찰 확정을 되돌리지 않는다(정산은 후속 재생성 가능). 스택트레이스 미노출. + } + return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt"))); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqCalc.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqCalc.java new file mode 100644 index 0000000..6ba05e2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqCalc.java @@ -0,0 +1,78 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqLine; + +import java.util.ArrayList; +import java.util.List; + +/** + * F035 물량서(BOQ) 자동산출 — 부스 설계 데이터(면적·둘레·부스수·유틸리티 주문)에서 + * 공사 물량 라인을 규칙 기반으로 집계하는 순수 함수(단위 테스트 대상, DB·스프링 미의존). + * + *

산식은 단순 명료(면적·주문 합산 기반) — 각 라인은 산출 근거({@code basis})를 함께 표기한다. + * 금액(단가)은 산출하지 않는다 — BOQ 는 물량(수량) 산출서이며, 단가·금액은 응찰 업체가 견적서로 채운다. + * + *

    + *
  • 구조: 시스템 골조 = 바닥면적(1:1) · 벽체 파티션 = 둘레 × 높이
  • + *
  • 바닥: 바닥 마감재 = 바닥면적(1:1)
  • + *
  • 조명: LED 조명기구 = ⌈면적 / 9㎡⌉ (9㎡당 1등)
  • + *
  • 전기: 분전반·전기 인입 = 부스 수(1실당 1)
  • + *
  • 설비: 급배수·네트워크 인입 = 유틸리티 신청 건수(있을 때만)
  • + *
+ */ +public final class BoqCalc { + + /** 기본 부스 높이(㎡ 데이터에 높이 미지정 시). */ + static final double DEFAULT_HEIGHT_M = 3.5; + /** 조명 밀도 — 9㎡당 1등. */ + static final double LIGHT_AREA_PER_FIXTURE = 9.0; + + private BoqCalc() { + } + + /** + * 물량 라인 산출. area/perimeter/height 는 M2 부스 폴리곤(㎡·m) 집계값, utilityOrderCount 는 M4 유틸리티 주문 수. + * boothCount==0 또는 area<=0 이면 빈 리스트(설계 미확정) — 서비스가 안내 note 를 붙인다. + */ + public static List compute(double areaM2, double perimeterM, int boothCount, + double heightM, int utilityOrderCount) { + List lines = new ArrayList<>(); + long area = Math.round(areaM2); + if (boothCount <= 0 || area <= 0) { + return lines; + } + // 둘레 미확보 시 정사각형 근사(4√면적)로 폴백 — 벽체 물량 근거를 명시. + boolean perimeterEstimated = perimeterM <= 0; + double perimeter = perimeterEstimated ? 4.0 * Math.sqrt(areaM2) : perimeterM; + double height = heightM > 0 ? heightM : DEFAULT_HEIGHT_M; + long wall = Math.round(perimeter * height); + long lights = (long) Math.ceil(areaM2 / LIGHT_AREA_PER_FIXTURE); + String pBasis = perimeterEstimated + ? "둘레(근사 " + Math.round(perimeter) + "m) × 높이 " + trimNum(height) + "m" + : "둘레 " + Math.round(perimeter) + "m × 높이 " + trimNum(height) + "m"; + + int seq = 0; + lines.add(new BoqLine(++seq, "구조", "시스템 부스 골조(알루미늄 프로파일)", "㎡", area, + "바닥면적 " + area + "㎡ 기준(1:1)")); + lines.add(new BoqLine(++seq, "구조", "벽체·파티션", "㎡", wall, pBasis)); + lines.add(new BoqLine(++seq, "바닥", "바닥 마감재(데코타일/카펫)", "㎡", area, + "바닥면적 " + area + "㎡ 기준(1:1)")); + lines.add(new BoqLine(++seq, "조명", "LED 조명기구", "EA", lights, + "9㎡당 1등 기준(면적 " + area + "㎡)")); + lines.add(new BoqLine(++seq, "전기", "분전반·전기 인입", "식", boothCount, + "부스 " + boothCount + "실 × 1분전반")); + if (utilityOrderCount > 0) { + lines.add(new BoqLine(++seq, "설비", "급배수·네트워크 인입", "식", utilityOrderCount, + "유틸리티 신청 " + utilityOrderCount + "건 기준")); + } + return lines; + } + + /** 소수 0이면 정수로 표기(3.5 → "3.5", 4.0 → "4"). */ + private static String trimNum(double v) { + if (v == Math.floor(v)) { + return String.valueOf((long) v); + } + return String.valueOf(Math.round(v * 10.0) / 10.0); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqService.java new file mode 100644 index 0000000..df252e3 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqService.java @@ -0,0 +1,103 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqLine; +import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqResult; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * F035 물량서(BOQ) 자동산출 서비스 — 옥션 자료 패키지 첨부용. + * + *

부스 설계 데이터(M2 booth/layout·M4 utility_order — 읽기 전용)에서 공사 물량을 + * 규칙 기반으로 집계한다({@link BoqCalc}). 옥션 제목에서 부스코드를 추출해 해당 부스로 스코프를 + * 좁히고, 매칭 부스가 없으면 행사 전체 부스로 집계한다. 설계 데이터가 없으면 안내 note 를 반환한다. + */ +@Service +public class BoqService { + + private static final Pattern BOOTH_CODE = Pattern.compile("([A-Za-z]-?\\d{1,4})"); + + private final AuctionMapper mapper; + + public BoqService(AuctionMapper mapper) { + this.mapper = mapper; + } + + public BoqResult boq(String auctionId) { + Map a = mapper.findAuction(auctionId); + if (a == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + String eventId = str(a.get("eventId")); + String boothCode = boothCode(str(a.get("title"))); + + // 1) 제목의 부스코드로 단일 부스 시도 → 없으면 행사 전체 집계로 폴백. + String scope = "booth"; + String scopeLabel = boothCode; + Map agg = boothCode == null ? null : mapper.boqBoothAggregate(eventId, boothCode); + int boothCount = agg == null ? 0 : intOr(agg.get("boothCount"), 0); + if (boothCount == 0) { + agg = mapper.boqBoothAggregate(eventId, null); + boothCount = agg == null ? 0 : intOr(agg.get("boothCount"), 0); + scope = "event"; + scopeLabel = "행사 전체"; + } + + double areaM2 = agg == null ? 0 : dbl(agg.get("areaM2")); + double perimeterM = agg == null ? 0 : dbl(agg.get("perimeterM")); + double heightM = agg == null ? 0 : dbl(agg.get("maxHeightM")); + + String boothNoFilter = "booth".equals(scope) ? boothCode : null; + Map util = mapper.boqUtilityAggregate(eventId, boothNoFilter); + int utilityCount = util == null ? 0 : intOr(util.get("orderCount"), 0); + + List lines = BoqCalc.compute(areaM2, perimeterM, boothCount, heightM, utilityCount); + String note = lines.isEmpty() + ? "배치·설계 데이터가 아직 확정되지 않아 물량이 산출되지 않았습니다. " + + "배치도(M2)·설계안(M3)·유틸리티(M4) 확정 후 자동 산출됩니다." + : "규칙 기반 자동 산출값입니다. 단가·금액은 응찰 견적서에서 산정됩니다."; + + return new BoqResult(auctionId, eventId, scope, scopeLabel, + boothCount, Math.round(areaM2), lines, note, Instant.now().toString()); + } + + /** 제목에서 부스코드 추출(예: "A-102 독립부스 시공" → "A-102"). 없으면 null → 행사 전체 스코프. */ + private static String boothCode(String title) { + if (title == null || title.isBlank()) { + return null; + } + Matcher m = BOOTH_CODE.matcher(title); + return m.find() ? m.group(1) : null; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static int intOr(Object o, int def) { + if (o == null) return def; + if (o instanceof Number n) return n.intValue(); + try { + return Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return def; + } + } + + private static double dbl(Object o) { + if (o == null) return 0.0; + if (o instanceof Number n) return n.doubleValue(); + try { + return Double.parseDouble(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0.0; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java index 2198934..122d681 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java @@ -28,6 +28,22 @@ public final class AuctionDtos { public record MaterialPackage(String layoutUrl, String designUrl, String boqUrl, String aiImageUrl) { } + /** + * F035 물량서(BOQ) 라인 — 공종·품명·단위·수량·산출근거. 금액(단가)은 미포함(응찰 업체가 견적서로 채움). + */ + public record BoqLine(int seq, String trade, String item, String unit, long qty, String basis) { + } + + /** + * F035 물량서(BOQ) 자동산출 결과 — 옥션 자료 패키지 첨부용. + * scope = booth(제목의 부스코드로 매칭한 단일 부스) | event(행사 전체 부스 집계). + * lines 가 비어 있으면 설계 미확정 — note 로 사유 안내. + */ + public record BoqResult( + String auctionId, String eventId, String scope, String scopeLabel, + int boothCount, long totalAreaM2, List lines, String note, String generatedAt) { + } + /** * 실시간 순위 행(응찰자 뷰 · 봉인). priceMasked=true 이면 price 는 null(타사 비공개). * alias 는 위치 라벨(업체명 아님) — 업체 식별 불가. diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java index 840039a..6d8b1eb 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java @@ -52,7 +52,8 @@ public class EventAccessGuard { return; } String eventTenant = eventTenantMapper.findEventTenant(eventId); - if (eventTenant != null && !eventTenant.equals(principal.tenantId())) { + // 테넌트 코드 대소문자 정규화 비교 — 표준 대문자 코드('KINTEX')와 레거시 소문자('kintex') 혼재 하위호환. + if (eventTenant != null && !eventTenant.equalsIgnoreCase(principal.tenantId())) { throw new ApiException(ErrorCode.FORBIDDEN); } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java index c4c72a0..d007db1 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java @@ -32,7 +32,13 @@ public class JwtService { } /** 기준 테넌트(#1 KINTEX) — TenantContext.DEFAULT_TENANT와 일치. tid 미지정·공백 시 폴백(회귀 0). */ - private static final String DEFAULT_TENANT = "kintex"; + private static final String DEFAULT_TENANT = "KINTEX"; + + /** 테넌트 코드 정규화(trim+대문자) — 소문자·레거시 tid를 표준 대문자 코드로 매칭. */ + private static String normalizeTenant(String tid) { + String v = com.zioinfo.kintex.tenant.TenantContext.normalize(tid); + return v == null ? DEFAULT_TENANT : v; + } /** * 액세스 토큰 발급. eventRoles: 행사ID→역할. roleCode: 전역 역할(app_user.role_code, null 허용). @@ -51,7 +57,7 @@ public class JwtService { .claim("name", displayName) .claim("roles", rolesClaim) .claim("hm", hallManager) - .claim("tid", (tenantId == null || tenantId.isBlank()) ? DEFAULT_TENANT : tenantId); + .claim("tid", normalizeTenant(tenantId)); if (roleCode != null && !roleCode.isBlank()) { builder.claim("rc", roleCode); // 순증 클레임 — /me 전역 역할 복원용 } @@ -87,9 +93,8 @@ public class JwtService { } boolean hm = Boolean.TRUE.equals(c.get("hm", Boolean.class)); String roleCode = c.get("rc", String.class); - // tid 부재(2단계 이전 발급 토큰) = 기준 테넌트 'kintex'로 복원 → 하위호환·회귀 0. - String tid = c.get("tid", String.class); - String tenantId = (tid == null || tid.isBlank()) ? DEFAULT_TENANT : tid; + // tid 부재(2단계 이전 발급 토큰)·소문자('kintex') = 대문자 정규화 후 기준 테넌트 'KINTEX'로 복원(하위호환·회귀 0). + String tenantId = normalizeTenant(c.get("tid", String.class)); return new KintexPrincipal(c.getSubject(), c.get("name", String.class), roles, hm, roleCode, tenantId); } catch (JwtException | IllegalArgumentException e) { throw new ApiException(ErrorCode.UNAUTHORIZED, "토큰이 유효하지 않거나 만료되었습니다."); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java index 65f1d16..2828154 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java @@ -23,7 +23,7 @@ public record KintexPrincipal( ) { /** 기준 테넌트(#1 KINTEX) — {@code com.zioinfo.kintex.tenant.TenantContext.DEFAULT_TENANT}와 일치. */ - private static final String DEFAULT_TENANT = "kintex"; + private static final String DEFAULT_TENANT = "KINTEX"; /** 하위호환 보조 생성자 — 테넌트 미상(tid 없는 기존 토큰) 경로용. 기존 5-인자 호출 유지·테넌트는 기준값. */ public KintexPrincipal(String userId, String displayName, diff --git a/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesController.java b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesController.java new file mode 100644 index 0000000..c814b2a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesController.java @@ -0,0 +1,96 @@ +package com.zioinfo.kintex.boothsales; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.boothsales.dto.BoothSalesDtos.*; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.settlement.dto.SettlementDtos.GenerateResult; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * F005/F006 부스 판매 인벤토리·온라인 셀프 판매 API (SCR /booth-sales). + *

    + *
  • 조회(인벤토리): 행사 접근 권한(참가업체는 available+본인 hold, 매니저는 전 상태).
  • + *
  • hold·구매 확정·본인 hold 해제: 행사 멤버(참가업체 포함).
  • + *
  • blocked 전환·판매가 override·정산 연계: 매니저 이상(주최자/홀매니저) + {@link Audited}.
  • + *
+ * 이중 hold·만료 hold 구매 시도는 409(CONFLICT). 금액은 서버 계산 권위. + */ +@RestController +@RequestMapping("/api/booth-sales") +public class BoothSalesController { + + private final BoothSalesService service; + private final EventAccessGuard guard; + + public BoothSalesController(BoothSalesService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET /api/booth-sales/inventory?eventId=&hallId= — 판매 인벤토리(상태·가격 오버레이). */ + @GetMapping("/inventory") + public ApiResponse inventory(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam String eventId, + @RequestParam(required = false) String hallId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.inventory(eventId, hallId, principal)); + } + + /** POST /api/booth-sales/hold — 부스 선점(참가업체 셀프 선택). 이중 hold 409. */ + @Audited(action = "BOOTH_SALE_HOLD", targetType = "booth_sale") + @PostMapping("/hold") + public ApiResponse hold(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody HoldRequest req) { + guard.requireEventAccess(principal, req.eventId()); + return ApiResponse.ok(service.hold(req, principal)); + } + + /** POST /api/booth-sales/purchase — 구매 확정(hold→sold). 만료·미보유 시 409. */ + @Audited(action = "BOOTH_SALE_PURCHASE", targetType = "booth_sale") + @PostMapping("/purchase") + public ApiResponse purchase(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody PurchaseRequest req) { + guard.requireEventAccess(principal, req.eventId()); + return ApiResponse.ok(service.purchase(req, principal)); + } + + /** POST /api/booth-sales/release — 본인 hold 해제(available 복원). */ + @Audited(action = "BOOTH_SALE_RELEASE", targetType = "booth_sale") + @PostMapping("/release") + public ApiResponse release(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody HoldRequest req) { + guard.requireEventAccess(principal, req.eventId()); + return ApiResponse.ok(service.release(req, principal)); + } + + /** POST /api/booth-sales/block — blocked/available 전환(매니저 이상). sold 차단 불가 409. */ + @Audited(action = "BOOTH_SALE_BLOCK", targetType = "booth_sale") + @PostMapping("/block") + public ApiResponse block(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody BlockRequest req) { + guard.requireEventAccess(principal, req.eventId()); + return ApiResponse.ok(service.setBlocked(req, principal)); + } + + /** POST /api/booth-sales/price — 판매가 override(매니저 이상, null=면적×요율 파생 복원). */ + @Audited(action = "BOOTH_SALE_PRICE", targetType = "booth_sale") + @PostMapping("/price") + public ApiResponse price(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody PriceRequest req) { + guard.requireEventAccess(principal, req.eventId()); + return ApiResponse.ok(service.setPrice(req, principal)); + } + + /** POST /api/booth-sales/settle?eventId= — M9 청구서 집계 트리거(매니저 이상, settlement 생성 연계). */ + @Audited(action = "BOOTH_SALE_SETTLE", targetType = "invoice") + @PostMapping("/settle") + public ApiResponse settle(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.settle(eventId, principal)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesMapper.java new file mode 100644 index 0000000..7cd59ac --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesMapper.java @@ -0,0 +1,215 @@ +package com.zioinfo.kintex.boothsales; + +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; + +/** + * F005/F006 부스 판매 매퍼 — booth_sale(판매 오버레이) + M2 booth/layout(좌표, read-only). + *

★ Map 반환 @Select 별칭은 반드시 쌍따옴표(AS "x") — PG 는 따옴표 없는 별칭을 소문자로 접어 + * Map 키가 null 이 된다(WORK_STATUS §7). 선택 파라미터(null)는 {@code COALESCE(CAST(#{p} AS type), 대체)} + * 로 흡수해 PG 파라미터 타입 미결정 오류를 회피한다. ★{@code + """) + int updateDoc(Map p); + + // ─────────────────────────────── 결재함 조회(box: DRAFT/DRAFTED/PENDING/DONE) + @Select(""" + + """) + List> searchBox(Map q); + + @Select(""" + + """) + long countBox(Map q); + + // ─────────────────────────────── 결재선 + @Delete("DELETE FROM approval_line WHERE approval_id = #{approvalId}") + int deleteLines(@Param("approvalId") String approvalId); + + @Insert(""" + INSERT INTO approval_line (id, approval_id, step_no, approver_id, approver_name, line_status) + VALUES (#{id}, #{approvalId}, #{stepNo}, #{approverId}, #{approverName}, 'WAIT') + """) + int insertLine(Map p); + + @Select(""" + SELECT id, step_no AS "stepNo", approver_id AS "approverId", approver_name AS "approverName", + line_status AS "lineStatus", + to_char(acted_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "actedAt", + comment + FROM approval_line WHERE approval_id = #{approvalId} ORDER BY step_no ASC + """) + List> findLines(@Param("approvalId") String approvalId); + + @Update(""" + + """) + int updateLine(Map p); + + /** 상신/재상신 시 전 단계 초기화(WAIT·의견/처리시각 제거). */ + @Update(""" + UPDATE approval_line + SET line_status='WAIT', acted_at=NULL, comment=NULL + WHERE approval_id = #{approvalId} + """) + int resetLines(@Param("approvalId") String approvalId); + + // ─────────────────────────────── 이력 + @Insert(""" + INSERT INTO approval_history (id, approval_id, step_no, actor_id, actor_name, action, comment) + VALUES (#{id}, #{approvalId}, #{stepNo}, #{actorId}, #{actorName}, #{action}, #{comment}) + """) + int insertHistory(Map p); + + @Select(""" + SELECT id, step_no AS "stepNo", actor_id AS "actorId", actor_name AS "actorName", + action, comment, + to_char(acted_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "actedAt" + FROM approval_history WHERE approval_id = #{approvalId} ORDER BY acted_at ASC, id ASC + """) + List> findHistory(@Param("approvalId") String approvalId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/approval/ApprovalService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/ApprovalService.java new file mode 100644 index 0000000..53298ed --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/ApprovalService.java @@ -0,0 +1,432 @@ +package com.zioinfo.kintex.work.approval; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalActionRequest; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalDetailDto; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalHistoryDto; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalLineDto; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalLineSaveRequest; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalListDto; +import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalSaveRequest; +import com.zioinfo.kintex.work.notification.NotificationService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * §5B 공통 전자결재(F028) 서비스 — 상태머신 서버 권위(WISE/UIWS approval 이식). + * + *

직렬 다단계(step 1→N 순차) 결재. 상태 전이: + * DRAFT →(submit)→ SUBMITTED →(approve 중간)→ IN_PROGRESS →(approve 마지막)→ APPROVED, + * (reject)→ REJECTED →(resubmit, version+1)→ SUBMITTED, (cancel)→ CANCELED. + * 승인/반려는 현재 단계 결재선 당사자만 가능(그 외 403). 조회는 기안자·결재선 참여자만. + * 알림은 {@link NotificationService#notify}(TYPE=APPROVAL) 재사용(신규 채널 없음). + */ +@Service +public class ApprovalService { + + private final ApprovalMapper mapper; + private final NotificationService notificationService; + + public ApprovalService(ApprovalMapper mapper, NotificationService notificationService) { + this.mapper = mapper; + this.notificationService = notificationService; + } + + // ─────────────────────────────── 결재함 목록 + public PageResponse list(KintexPrincipal principal, String box, + String keyword, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + String b = (box == null || box.isBlank()) ? "DRAFTED" : box.trim().toUpperCase(); + if (!List.of("DRAFT", "DRAFTED", "PENDING", "DONE").contains(b)) { + throw new ApiException(ErrorCode.VALIDATION, "box 는 DRAFT/DRAFTED/PENDING/DONE 중 하나여야 합니다."); + } + Map q = new HashMap<>(); + q.put("tenantId", principal.tenantId()); + q.put("me", principal.userId()); + q.put("box", b); + q.put("keyword", (keyword == null || keyword.isBlank()) ? null : keyword.trim()); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.searchBox(q); + long total = mapper.countBox(q); + return PageResponse.of(rows.stream().map(ApprovalService::toListDto).toList(), p, s, total); + } + + // ─────────────────────────────── 상세 + public ApprovalDetailDto get(KintexPrincipal principal, String id) { + Map a = load(id); + assertTenant(principal, a); + List> lines = mapper.findLines(id); + assertReadable(principal, a, lines); + return toDetail(principal, a, lines, mapper.findHistory(id)); + } + + // ─────────────────────────────── 기안(DRAFT) + @Transactional + public ApprovalDetailDto create(KintexPrincipal principal, ApprovalSaveRequest req) { + String id = "ap-" + UUID.randomUUID().toString().substring(0, 12); + Map p = new HashMap<>(); + p.put("id", id); + p.put("tenantId", principal.tenantId()); + p.put("eventId", req.eventId()); + p.put("docType", req.docType()); + p.put("title", req.title()); + p.put("content", req.content()); + p.put("refType", req.refType()); + p.put("refId", req.refId()); + p.put("drafterId", principal.userId()); + p.put("drafterName", principal.displayName()); + mapper.insert(p); + replaceLines(id, req.lines()); + return get(principal, id); + } + + // ─────────────────────────────── 상신(DRAFT→SUBMITTED) + @Transactional + public ApprovalDetailDto submit(KintexPrincipal principal, String id) { + Map a = load(id); + assertTenant(principal, a); + assertDrafter(principal, a); + if (!"DRAFT".equals(status(a))) { + throw new ApiException(ErrorCode.CONFLICT, "임시저장(DRAFT) 상태만 상신할 수 있습니다."); + } + startPending(principal, id, "SUBMIT"); + return get(principal, id); + } + + // ─────────────────────────────── 승인 + @Transactional + public ApprovalDetailDto approve(KintexPrincipal principal, String id, ApprovalActionRequest req) { + Map a = load(id); + assertTenant(principal, a); + assertInFlight(a); + Map current = requireCurrentApprover(principal, id); + String comment = req == null ? null : req.comment(); + + Map up = new HashMap<>(); + up.put("id", str(current.get("id"))); + up.put("lineStatus", "APPROVED"); + up.put("comment", comment); + up.put("acted", true); + mapper.updateLine(up); + addHistory(principal, id, intVal(current.get("stepNo")), "APPROVE", comment); + + Map next = nextWaiting(mapper.findLines(id), intVal(current.get("stepNo"))); + if (next != null) { + Map nl = new HashMap<>(); + nl.put("id", str(next.get("id"))); + nl.put("lineStatus", "CURRENT"); + mapper.updateLine(nl); + Map doc = new HashMap<>(); + doc.put("id", id); + doc.put("status", "IN_PROGRESS"); + doc.put("curStep", intVal(next.get("stepNo"))); + mapper.updateDoc(doc); + notify(str(next.get("approverId")), a, "[결재 요청] " + str(a.get("title")), + "결재할 문서가 도착했습니다.", id); + } else { + complete(a, id); + } + return get(principal, id); + } + + // ─────────────────────────────── 반려(사유 필수) + @Transactional + public ApprovalDetailDto reject(KintexPrincipal principal, String id, ApprovalActionRequest req) { + Map a = load(id); + assertTenant(principal, a); + assertInFlight(a); + Map current = requireCurrentApprover(principal, id); + String comment = req == null ? null : req.comment(); + if (comment == null || comment.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "반려 사유(의견)를 입력해야 합니다."); + } + Map up = new HashMap<>(); + up.put("id", str(current.get("id"))); + up.put("lineStatus", "REJECTED"); + up.put("comment", comment); + up.put("acted", true); + mapper.updateLine(up); + addHistory(principal, id, intVal(current.get("stepNo")), "REJECT", comment); + + Map doc = new HashMap<>(); + doc.put("id", id); + doc.put("status", "REJECTED"); + doc.put("completedAt", true); + mapper.updateDoc(doc); + notify(str(a.get("drafterId")), a, "[결재 반려] " + str(a.get("title")), + "결재 문서가 반려되었습니다.", id); + return get(principal, id); + } + + // ─────────────────────────────── 재상신(REJECTED→SUBMITTED, version+1) + @Transactional + public ApprovalDetailDto resubmit(KintexPrincipal principal, String id, ApprovalSaveRequest req) { + Map a = load(id); + assertTenant(principal, a); + assertDrafter(principal, a); + if (!"REJECTED".equals(status(a))) { + throw new ApiException(ErrorCode.CONFLICT, "반려(REJECTED) 상태만 재상신할 수 있습니다."); + } + Map doc = new HashMap<>(); + doc.put("id", id); + doc.put("version", intVal(a.get("version")) + 1); + doc.put("clearCompleted", true); + if (req != null) { + if (req.title() != null && !req.title().isBlank()) { + doc.put("title", req.title().trim()); + } + if (req.content() != null) { + doc.put("content", req.content()); + } + if (req.docType() != null && !req.docType().isBlank()) { + doc.put("docType", req.docType().trim()); + } + } + mapper.updateDoc(doc); + if (req != null && req.lines() != null && !req.lines().isEmpty()) { + replaceLines(id, req.lines()); + } + startPending(principal, id, "RESUBMIT"); + return get(principal, id); + } + + // ─────────────────────────────── 회수(기안자 취소) + @Transactional + public ApprovalDetailDto cancel(KintexPrincipal principal, String id) { + Map a = load(id); + assertTenant(principal, a); + assertDrafter(principal, a); + String st = status(a); + if (!"DRAFT".equals(st) && !"SUBMITTED".equals(st)) { + throw new ApiException(ErrorCode.CONFLICT, "임시저장/상신 상태만 회수할 수 있습니다."); + } + Map doc = new HashMap<>(); + doc.put("id", id); + doc.put("status", "CANCELED"); + doc.put("completedAt", true); + mapper.updateDoc(doc); + addHistory(principal, id, intVal(a.get("curStep")), "CANCEL", null); + return get(principal, id); + } + + // ─────────────────────────────── 상태머신 헬퍼 + + /** DRAFT/REJECTED→SUBMITTED 공통: 결재선 필수·전 단계 리셋·step1 CURRENT·cur_step=1·알림. */ + private void startPending(KintexPrincipal principal, String id, String action) { + List> lines = mapper.findLines(id); + if (lines.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION, "결재선을 1명 이상 지정해야 합니다."); + } + mapper.resetLines(id); + Map first = lines.get(0); + Map fl = new HashMap<>(); + fl.put("id", str(first.get("id"))); + fl.put("lineStatus", "CURRENT"); + mapper.updateLine(fl); + + Map doc = new HashMap<>(); + doc.put("id", id); + doc.put("status", "SUBMITTED"); + doc.put("curStep", intVal(first.get("stepNo"))); + doc.put("submittedAt", true); + mapper.updateDoc(doc); + addHistory(principal, id, intVal(first.get("stepNo")), action, null); + + Map a = load(id); + notify(str(first.get("approverId")), a, "[결재 요청] " + str(a.get("title")), + "결재할 문서가 도착했습니다.", id); + } + + private void complete(Map a, String id) { + Map doc = new HashMap<>(); + doc.put("id", id); + doc.put("status", "APPROVED"); + doc.put("completedAt", true); + mapper.updateDoc(doc); + notify(str(a.get("drafterId")), a, "[결재 완료] " + str(a.get("title")), + "결재 문서가 최종 승인되었습니다.", id); + } + + private void replaceLines(String approvalId, List lineReqs) { + mapper.deleteLines(approvalId); + if (lineReqs == null || lineReqs.isEmpty()) { + return; + } + List sorted = lineReqs.stream() + .sorted((x, y) -> Integer.compare(step(x), step(y))) + .toList(); + int expected = 1; + for (ApprovalLineSaveRequest d : sorted) { + if (step(d) != expected) { + throw new ApiException(ErrorCode.VALIDATION, + "결재선 단계는 1부터 연속이어야 합니다(기대: " + expected + ")."); + } + if (d.approverId() == null || d.approverId().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "결재자 ID가 비어 있습니다."); + } + Map p = new HashMap<>(); + p.put("id", "al-" + UUID.randomUUID().toString().substring(0, 12)); + p.put("approvalId", approvalId); + p.put("stepNo", expected); + p.put("approverId", d.approverId().trim()); + p.put("approverName", d.approverName()); + mapper.insertLine(p); + expected++; + } + } + + private void addHistory(KintexPrincipal principal, String approvalId, Integer stepNo, + String action, String comment) { + Map p = new HashMap<>(); + p.put("id", "ah-" + UUID.randomUUID().toString().substring(0, 12)); + p.put("approvalId", approvalId); + p.put("stepNo", stepNo); + p.put("actorId", principal.userId()); + p.put("actorName", principal.displayName()); + p.put("action", action); + p.put("comment", comment); + mapper.insertHistory(p); + } + + private void notify(String recipientId, Map a, String title, String message, String id) { + if (recipientId == null || recipientId.isBlank()) { + return; + } + try { + notificationService.notify(recipientId, str(a.get("eventId")), "APPROVAL", + title, message, "/work/approvals/" + id); + } catch (RuntimeException ignore) { + // 알림 실패는 결재 흐름을 막지 않는다(실패 격리). + } + } + + private Map nextWaiting(List> lines, int afterStep) { + return lines.stream() + .filter(l -> intVal(l.get("stepNo")) > afterStep && "WAIT".equals(str(l.get("lineStatus")))) + .min((x, y) -> Integer.compare(intVal(x.get("stepNo")), intVal(y.get("stepNo")))) + .orElse(null); + } + + private Map requireCurrentApprover(KintexPrincipal principal, String id) { + Map current = mapper.findLines(id).stream() + .filter(l -> "CURRENT".equals(str(l.get("lineStatus")))) + .findFirst() + .orElseThrow(() -> new ApiException(ErrorCode.CONFLICT, "현재 진행 단계가 없습니다.")); + if (!principal.userId().equals(str(current.get("approverId")))) { + throw new ApiException(ErrorCode.FORBIDDEN, "현재 단계 결재자만 처리할 수 있습니다."); + } + return current; + } + + // ─────────────────────────────── 권한/가시 + private void assertDrafter(KintexPrincipal principal, Map a) { + if (!principal.userId().equals(str(a.get("drafterId")))) { + throw new ApiException(ErrorCode.FORBIDDEN, "기안자만 수행할 수 있습니다."); + } + } + + private void assertInFlight(Map a) { + String st = status(a); + if (!"SUBMITTED".equals(st) && !"IN_PROGRESS".equals(st)) { + throw new ApiException(ErrorCode.CONFLICT, "진행중인 문서만 결재할 수 있습니다."); + } + } + + private void assertTenant(KintexPrincipal principal, Map a) { + String t = str(a.get("tenantId")); + if (t != null && !t.equals(principal.tenantId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + } + + /** 조회 가능: 기안자 본인 또는 결재선 참여자만(은닉). */ + private void assertReadable(KintexPrincipal principal, Map a, List> lines) { + String me = principal.userId(); + if (me.equals(str(a.get("drafterId")))) { + return; + } + boolean inLine = lines.stream().anyMatch(l -> me.equals(str(l.get("approverId")))); + if (!inLine) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + private Map load(String id) { + Map a = mapper.findById(id); + if (a == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return a; + } + + private static int step(ApprovalLineSaveRequest d) { + return d.stepNo() == null ? 0 : d.stepNo(); + } + + // ─────────────────────────────── 매핑 + private static ApprovalListDto toListDto(Map r) { + return new ApprovalListDto( + str(r.get("id")), str(r.get("docType")), str(r.get("title")), str(r.get("status")), + str(r.get("drafterId")), str(r.get("drafterName")), + intVal(r.get("curStep")), intVal(r.get("totalSteps")), intVal(r.get("version")), + str(r.get("eventId")), str(r.get("refType")), str(r.get("refId")), + str(r.get("submittedAt")), str(r.get("createdAt"))); + } + + private ApprovalDetailDto toDetail(KintexPrincipal principal, Map a, + List> lineRows, List> histRows) { + String me = principal.userId(); + String st = str(a.get("status")); + List lines = lineRows.stream().map(l -> new ApprovalLineDto( + str(l.get("id")), intVal(l.get("stepNo")), str(l.get("approverId")), str(l.get("approverName")), + str(l.get("lineStatus")), str(l.get("actedAt")), str(l.get("comment")))).toList(); + List history = histRows.stream().map(h -> new ApprovalHistoryDto( + str(h.get("id")), h.get("stepNo") == null ? null : intVal(h.get("stepNo")), + str(h.get("actorId")), str(h.get("actorName")), + str(h.get("action")), str(h.get("comment")), str(h.get("actedAt")))).toList(); + + boolean inFlight = "SUBMITTED".equals(st) || "IN_PROGRESS".equals(st); + boolean canAct = inFlight && lineRows.stream() + .anyMatch(l -> "CURRENT".equals(str(l.get("lineStatus"))) && me.equals(str(l.get("approverId")))); + boolean isDrafter = me.equals(str(a.get("drafterId"))); + boolean canCancel = isDrafter && ("DRAFT".equals(st) || "SUBMITTED".equals(st)); + boolean canResubmit = isDrafter && "REJECTED".equals(st); + + return new ApprovalDetailDto( + str(a.get("id")), str(a.get("docType")), str(a.get("title")), str(a.get("content")), st, + str(a.get("drafterId")), str(a.get("drafterName")), + intVal(a.get("curStep")), lineRows.size(), intVal(a.get("version")), + str(a.get("eventId")), str(a.get("refType")), str(a.get("refId")), + str(a.get("submittedAt")), str(a.get("completedAt")), str(a.get("createdAt")), + lines, history, canAct, canCancel, canResubmit); + } + + private static String status(Map a) { + return str(a.get("status")); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static int intVal(Object o) { + if (o instanceof Number n) { + return n.intValue(); + } + try { + return o == null ? 0 : Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/approval/dto/ApprovalDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/dto/ApprovalDtos.java new file mode 100644 index 0000000..90853e6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/dto/ApprovalDtos.java @@ -0,0 +1,61 @@ +package com.zioinfo.kintex.work.approval.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +/** + * §5B 공통 전자결재(F028) DTO 모음 — WISE/UIWS approval 패턴 이식. + * 상태(문서): DRAFT→SUBMITTED→IN_PROGRESS→APPROVED|REJECTED (+CANCELED). + */ +public final class ApprovalDtos { + + private ApprovalDtos() { + } + + /** 결재함 목록 행. */ + public record ApprovalListDto( + String id, String docType, String title, String status, + String drafterId, String drafterName, + int curStep, int totalSteps, int version, + String eventId, String refType, String refId, + String submittedAt, String createdAt) { + } + + /** 결재선 단계. */ + public record ApprovalLineDto( + String id, int stepNo, String approverId, String approverName, + String lineStatus, String actedAt, String comment) { + } + + /** 처리 이력 항목. */ + public record ApprovalHistoryDto( + String id, Integer stepNo, String actorId, String actorName, + String action, String comment, String actedAt) { + } + + /** 문서 상세(결재선·이력·액션 가능 플래그 포함). */ + public record ApprovalDetailDto( + String id, String docType, String title, String content, String status, + String drafterId, String drafterName, + int curStep, int totalSteps, int version, + String eventId, String refType, String refId, + String submittedAt, String completedAt, String createdAt, + List lines, List history, + boolean canAct, boolean canCancel, boolean canResubmit) { + } + + /** 결재선 저장 항목(기안/재상신). */ + public record ApprovalLineSaveRequest(Integer stepNo, @NotBlank String approverId, String approverName) { + } + + /** 기안/재상신 저장 요청. lines 는 상신 시 필수(1부터 연속). */ + public record ApprovalSaveRequest( + String eventId, String docType, @NotBlank String title, String content, + String refType, String refId, List lines) { + } + + /** 승인/반려 액션 요청(의견). 반려는 comment 필수(서비스 검증). */ + public record ApprovalActionRequest(String comment) { + } +} diff --git a/src/backend/src/main/resources/db/migration/V27__booth_sales.sql b/src/backend/src/main/resources/db/migration/V27__booth_sales.sql new file mode 100644 index 0000000..cd6dc24 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V27__booth_sales.sql @@ -0,0 +1,40 @@ +-- 킨텍스 — F005 부스 판매 인벤토리 + F006 온라인 셀프 선택·판매 스키마 (멱등) +-- PLANNING/FEATURE_GAP F005/F006: 행사×홀의 부스 "판매 상태" 오버레이(available|held|sold|blocked). +-- 기존 M2 공간 데이터(booth POLYGON·layout)를 판매 단위로 소비(읽기만) — 본 마이그레이션은 M2 스키마 불변. +-- 가격은 서버 권위: 기본 = ST_Area(booth.geom) × 요율(master_data RATE.exhibitionHallPerM2), 주최자 override 가능. +-- hold 동시성: UPDATE ... WHERE status='available'(또는 만료 held) 원자 전이 + (event_id, booth_id) 유니크. +-- 보안 불변: 본 마이그레이션은 V27 단독. V1~V26 및 타 팀 마이그레이션을 변경하지 않는다. +-- 멀티테넌시 표준(소유자 지시 2026-07-12): tenant_id = PK 선두 복합키 + DEFAULT 'KINTEX'. + +-- 부스 판매 오버레이(BoothSale) — booth(booth_id) 1행 대응. booth_id 는 M2 booth 소프트 참조(FK 미설정: +-- 배치 재저장 시 booth 교체 가능성). status 전이는 서비스 원자 UPDATE 로 제어. -------------------- +CREATE TABLE IF NOT EXISTS booth_sale ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + hall_id varchar(20) NOT NULL REFERENCES hall(id), + booth_id varchar(40) NOT NULL, -- M2 booth.id 소프트 참조 + booth_no varchar(40), -- 표시용 부스 번호(스냅샷) + status varchar(20) NOT NULL DEFAULT 'available', -- available|held|sold|blocked + unit_price bigint, -- 주최자 override(원). null = 면적×요율 파생 + area_m2 numeric(10,2), -- 판매면적 스냅샷(참고) + held_by varchar(40), -- hold 점유 사용자 + held_until timestamptz, -- hold 만료(TTL) — 경과 시 lazy available + sold_to_company varchar(200), -- 구매 확정 참가업체명(표시용, 민감정보 아님) + sold_by varchar(40), + sold_at timestamptz, + blocked_reason varchar(300), + created_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (event_id, booth_id), -- 부스당 1 판매 상태(동시 hold 이중 방지) + UNIQUE (id), -- 단일 id 조회·FK 대상 보존 + PRIMARY KEY (tenant_id, id) -- ★표준: tenant_id 선두 복합 PK +); +CREATE INDEX IF NOT EXISTS idx_booth_sale_scope ON booth_sale (tenant_id, event_id, hall_id, status); +CREATE INDEX IF NOT EXISTS idx_booth_sale_held ON booth_sale (status, held_until); +CREATE INDEX IF NOT EXISTS idx_booth_sale_booth ON booth_sale (event_id, booth_id); + +COMMENT ON TABLE booth_sale IS 'F005/F006 부스 판매 인벤토리 — M2 booth 좌표를 소비하는 판매 상태 오버레이(available|held|sold|blocked)'; +COMMENT ON COLUMN booth_sale.unit_price IS '서버 권위 판매가(원). null 이면 면적×요율(rates-v1.0) 파생'; +COMMENT ON COLUMN booth_sale.held_until IS 'hold TTL 만료 시각 — 경과분은 조회 시 available 로 lazy 만료'; diff --git a/src/backend/src/main/resources/db/migration/V28__boq_auction_fee.sql b/src/backend/src/main/resources/db/migration/V28__boq_auction_fee.sql new file mode 100644 index 0000000..7e54f11 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V28__boq_auction_fee.sql @@ -0,0 +1,43 @@ +-- 킨텍스 — F035 물량서(BOQ) 데모 부스 + F084 옥션 수수료·정산 연동 (멱등) +-- PLANNING FEATURE_GAP F035/F084: +-- F035 BOQ = 부스 설계 데이터(M2 booth/layout·M4 utility_order — 읽기)에서 규칙 기반 물량 집계(신규 테이블 불필요). +-- F084 옥션 수수료 = 낙찰 확정 시 수수료(요율 룰셋 RATE.auctionFeeRate, 없으면 기본 5%)를 +-- 정산 invoice(category='auction_fee', source_ref=낙찰 id)로 멱등 청구. +-- 보안 불변: 본 마이그레이션은 V28 단독. V1~V27 및 타 팀 마이그레이션을 변경하지 않는다. 전부 멱등. + +-- 1) 정산 청구서에 유형·출처 순증 (F084) ---------------------------------------- +-- 기존 청구는 DEFAULT 'rental'로 자동 백필(회귀 0). source_ref = 낙찰 id(멱등 키). +ALTER TABLE invoice ADD COLUMN IF NOT EXISTS category varchar(30) NOT NULL DEFAULT 'rental'; +ALTER TABLE invoice ADD COLUMN IF NOT EXISTS source_ref varchar(80); +COMMENT ON COLUMN invoice.category IS 'rental(임대·유틸) | auction_fee(옥션 수수료) — 정산 유형 구분(F084)'; +COMMENT ON COLUMN invoice.source_ref IS '출처 참조(옥션 수수료=award.id) — 낙찰당 수수료 1건 멱등 보장'; + +-- 낙찰당 수수료 청구 1건 — source_ref 부분 유니크(중복 생성 원천 차단) -------------- +CREATE UNIQUE INDEX IF NOT EXISTS uq_invoice_source_ref ON invoice (source_ref) WHERE source_ref IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_invoice_category ON invoice (event_id, category); + +-- 2) 옥션 수수료율 룰셋(%) — 없으면 서비스 기본 5% (F084) ------------------------- +INSERT INTO master_data (category, code, name, value_json, ruleset_version) VALUES + ('RATE', 'auctionFeeRate', '옥션 낙찰 수수료율(%)', '5'::jsonb, 'rates-v1.0') +ON CONFLICT (category, code) DO NOTHING; + +-- 3) BOQ 데모 부스 — AUC-2026-0102(제목 "A-102 …")가 실 물량으로 산출되게 (F035) ---- +-- SRID 0 = 홀 로컬 평면(미터). 12m × 9m = 108㎡ 폴리곤. 기존 event(e-2026-smf)·hall(H1) 참조. +-- assigned_company_name 은 기존 청구(inv-smf-0001)와 동일 → 정산 generate 시 멱등 스킵(중복 없음). +INSERT INTO layout (id, event_id, hall_id, version, name, status) VALUES + ('ly-smf-boq', 'e-2026-smf', 'H1', 1, 'SMF 데모 배치(BOQ)', 'confirmed') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO booth (id, layout_id, booth_no, booth_type, geom, size_w_m, size_d_m, height_m, + floor_load_t_per_m2, assigned_company_name, premium) VALUES + ('booth-smf-a102', 'ly-smf-boq', 'A-102', 'independent', + ST_GeomFromText('POLYGON((0 0,12 0,12 9,0 9,0 0))', 0), 12, 9, 3.5, 1.0, + '(주)스마트오토메이션', true) +ON CONFLICT (id) DO NOTHING; + +-- 유틸리티 신청 1건(전기·네트워크) — BOQ 설비 라인 근거 -------------------------- +INSERT INTO utility_order (id, event_id, booth_id, status, quote, supply_timing) VALUES + ('uo-smf-a102', 'e-2026-smf', 'booth-smf-a102', 'submitted', + '{"total":2340000,"items":[{"kind":"electric","amount":1800000},{"kind":"network","amount":540000}]}'::jsonb, + '반입일 D-1') +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V29__approval_workflow.sql b/src/backend/src/main/resources/db/migration/V29__approval_workflow.sql new file mode 100644 index 0000000..0e230d5 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V29__approval_workflow.sql @@ -0,0 +1,65 @@ +-- V29 §5B 공통 전자결재(F028) — 결재 문서 + 다단계 결재선 + 처리 이력 (멱등·tenant_id) +-- 규칙(불변): V1~V28 불변. 전부 CREATE ... IF NOT EXISTS 로 재실행 안전. +-- WISE/UIWS TB_APPROVAL/LINE/HISTORY 패턴 이식(kintex work 모듈 컨벤션: varchar id·tenant_id·소문자 테이블). +-- 상태(문서): DRAFT → SUBMITTED → IN_PROGRESS → APPROVED | REJECTED (+ CANCELED 회수) +-- 결재선(단계): WAIT / CURRENT / APPROVED / REJECTED / SKIPPED +-- 이력(액션): SUBMIT / APPROVE / REJECT / RESUBMIT / CANCEL +-- M6 서류 연계: ref_type='DOCUMENT' + ref_id(docType/booth 등)로 서류 검수 흐름과 느슨 결합. + +-- 1) 결재 문서 ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS approval ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_id varchar(40), + doc_type varchar(30) NOT NULL DEFAULT 'GENERAL', + title varchar(300) NOT NULL, + content text, + ref_type varchar(30), -- M6 연계 대상 유형(예: DOCUMENT) + ref_id varchar(120), -- M6 연계 대상 식별(예: 부스/docType) + drafter_id varchar(40) NOT NULL, + drafter_name varchar(120), + status varchar(20) NOT NULL DEFAULT 'DRAFT', + cur_step integer NOT NULL DEFAULT 0, -- 현재 진행 단계(1부터, DRAFT=0) + version integer NOT NULL DEFAULT 1, -- 재상신 시 증가 + submitted_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), -- 자식 FK(approval_id→approval.id) 대상 보존 + PRIMARY KEY (tenant_id, id) -- ★표준: tenant_id 선두 복합 PK +); +CREATE INDEX IF NOT EXISTS idx_approval_drafter ON approval (drafter_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_approval_event ON approval (event_id, status); +CREATE INDEX IF NOT EXISTS idx_approval_ref ON approval (ref_type, ref_id); +CREATE INDEX IF NOT EXISTS idx_approval_tenant ON approval (tenant_id, status, created_at DESC); + +-- 2) 결재선 단계 --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS approval_line ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + approval_id varchar(40) NOT NULL REFERENCES approval(id) ON DELETE CASCADE, + step_no integer NOT NULL, -- 1부터 연속 + approver_id varchar(40) NOT NULL, + approver_name varchar(120), + line_status varchar(20) NOT NULL DEFAULT 'WAIT', + acted_at timestamptz, + comment varchar(500), + PRIMARY KEY (tenant_id, id) -- ★표준 +); +CREATE INDEX IF NOT EXISTS idx_approval_line ON approval_line (approval_id, step_no); +CREATE INDEX IF NOT EXISTS idx_approval_line_approver ON approval_line (approver_id, line_status); + +-- 3) 처리 이력(감사·타임라인, 불변) -------------------------------------------- +CREATE TABLE IF NOT EXISTS approval_history ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + approval_id varchar(40) NOT NULL REFERENCES approval(id) ON DELETE CASCADE, + step_no integer, + actor_id varchar(40) NOT NULL, + actor_name varchar(120), + action varchar(20) NOT NULL, + comment varchar(500), + acted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, id) -- ★표준 +); +CREATE INDEX IF NOT EXISTS idx_approval_history ON approval_history (approval_id, acted_at); diff --git a/src/backend/src/main/resources/db/migration/V30__ai_config.sql b/src/backend/src/main/resources/db/migration/V30__ai_config.sql new file mode 100644 index 0000000..7e85620 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V30__ai_config.sql @@ -0,0 +1,21 @@ +-- V30: F100 AI 플랫폼 설정 (AiConfig — provider/모델/활성화 런타임 전환) +-- 규칙(불변): V1~V26 불변. 전부 멱등(CREATE ... IF NOT EXISTS / ON CONFLICT DO NOTHING) — 재실행 안전. +-- ★ 보안 불변: 이 테이블에는 API 키를 저장하지 않는다(ANTHROPIC_API_KEY 는 서버 env 전용). +-- provider/모델/활성화 플래그만 보관한다. 키 값·마스킹·env 사본 어떤 형태로도 저장 금지. + +CREATE TABLE IF NOT EXISTS ai_config ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', -- ★표준: tenant_id 선두 복합 PK(테넌트별 AI 설정) + config_key varchar(64) NOT NULL, -- ai.provider | ai.claude.model | ai.ollama.model | ai.enabled + config_val varchar(200) NOT NULL, + updated_by varchar(64), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, config_key) +); + +-- 기본값 시드(멱등). Claude 기본 + 소형 Ollama 폴백 모델. 키 미설정 시 라우터가 Ollama 로 자동 폴백한다. +INSERT INTO ai_config (tenant_id, config_key, config_val, updated_by) VALUES + ('KINTEX', 'ai.provider', 'claude', 'SYSTEM'), + ('KINTEX', 'ai.claude.model', 'claude-sonnet-4-6', 'SYSTEM'), + ('KINTEX', 'ai.ollama.model', 'qwen3:1.7b', 'SYSTEM'), + ('KINTEX', 'ai.enabled', 'true', 'SYSTEM') +ON CONFLICT (tenant_id, config_key) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V31__tenant_standard.sql b/src/backend/src/main/resources/db/migration/V31__tenant_standard.sql new file mode 100644 index 0000000..b911ea3 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V31__tenant_standard.sql @@ -0,0 +1,118 @@ +-- V31 소유자 데이터 표준 — 킨텍스 테넌트 표준화 (소유자 지시 2026-07-12) +-- 목표: ① 테넌트 코드 'kintex' → 'KINTEX'(대문자) 재명명(마스터 + 전 tenant_id 데이터/기본값) +-- ② tenant_id를 복합 PK의 선두 컬럼으로 전환(PRIMARY KEY (tenant_id, id)) +-- ③ 표준 선언: 신규 테넌트 소유 테이블은 생성 시부터 PRIMARY KEY (tenant_id, id) + DEFAULT 'KINTEX'. +-- 불변: V1~V30 파일 불변. 본 마이그레이션은 단일 트랜잭션(Flyway 기본) — 실패 시 전체 롤백. +-- 멱등: 전 단계 재실행 안전(존재 가드·복합 PK 스킵 가드·ON CONFLICT). +-- 하위호환: 기존 발급 JWT(tid='kintex')·X-Tenant-Id 소문자 입력은 백엔드에서 대문자 정규화 후 매칭(코드 정합 참조). + +-- ============================================================================ +-- 1) 테넌트 재명명 'kintex' → 'KINTEX' (마스터 + 전 tenant_id 보유 테이블 동적 스윕) +-- ============================================================================ +DO $$ +DECLARE + r record; +BEGIN + -- (a) 'KINTEX' 마스터 행 확보 — 기존 'kintex' 행 속성(domain/name/status/created_at) 복사, 없으면 기본값. + INSERT INTO tenant (id, name, domain, status, created_at) + SELECT 'KINTEX', name, domain, status, created_at + FROM tenant WHERE id = 'kintex' + ON CONFLICT (id) DO NOTHING; + + INSERT INTO tenant (id, name, domain, status) + VALUES ('KINTEX', 'KINTEX', 'kintex.zioinfo.co.kr', 'active') + ON CONFLICT (id) DO NOTHING; + + -- (b) tenant_id 컬럼을 보유한 모든 테이블(event·hall·app_user·booth_sale·approval 등)의 + -- 데이터 재지정 + DEFAULT 'KINTEX' 전환 — information_schema 동적 스윕(향후 tenant_id 추가분 포함). + FOR r IN + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND column_name = 'tenant_id' + LOOP + EXECUTE format('UPDATE %I SET tenant_id = ''KINTEX'' WHERE tenant_id = ''kintex''', r.table_name); + EXECUTE format('ALTER TABLE %I ALTER COLUMN tenant_id SET DEFAULT ''KINTEX''', r.table_name); + END LOOP; + + -- (c) 레거시 'kintex' 마스터 행 제거(이제 무참조 — tenant는 FK 대상이 아님, V26 확인). + DELETE FROM tenant WHERE id = 'kintex'; +END $$; + +-- ============================================================================ +-- 2) 복합 PK 전환 (tenant_id 선두) — event·hall·app_user +-- 절차: (a) 대상 테이블 id를 참조하는 FK를 pg_constraint에서 동적 수집·정의 보관 후 드롭 +-- (b) 단일 PK(id) 드롭 → PK(tenant_id, id) 추가 → UNIQUE(id) 추가(단일 id 참조 FK 보존) +-- (c) 보관한 FK를 UNIQUE(id) 대상으로 재생성 +-- 가드: PK에 tenant_id가 이미 포함(복합)이면 스킵(재실행 안전). +-- ============================================================================ +DO $$ +DECLARE + t text; + pk_name text; + is_composite boolean; + fk record; +BEGIN + CREATE TEMP TABLE _kx_fk_bak (child text, conname text, def text) ON COMMIT DROP; + + FOREACH t IN ARRAY ARRAY['event', 'hall', 'app_user'] LOOP + -- 이미 복합 PK(tenant_id 포함)면 스킵. + SELECT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a + ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = t::regclass + AND c.contype = 'p' + AND a.attname = 'tenant_id' + ) INTO is_composite; + + IF is_composite THEN + CONTINUE; + END IF; + + -- 현재 PK 제약 이름. + SELECT conname INTO pk_name + FROM pg_constraint + WHERE conrelid = t::regclass AND contype = 'p'; + + -- (a) 이 테이블(id)을 참조하는 FK 정의 보관 후 드롭. + TRUNCATE _kx_fk_bak; + INSERT INTO _kx_fk_bak (child, conname, def) + SELECT con.conrelid::regclass::text, con.conname, pg_get_constraintdef(con.oid) + FROM pg_constraint con + WHERE con.confrelid = t::regclass + AND con.contype = 'f'; + + FOR fk IN SELECT child, conname FROM _kx_fk_bak LOOP + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', fk.child, fk.conname); + END LOOP; + + -- (b) 단일 PK 드롭 → 복합 PK 추가 → UNIQUE(id) 추가. + IF pk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', t, pk_name); + END IF; + EXECUTE format('ALTER TABLE %I ADD PRIMARY KEY (tenant_id, id)', t); + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = t::regclass AND conname = t || '_id_key' + ) THEN + EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I UNIQUE (id)', t, t || '_id_key'); + END IF; + + -- (c) 보관 FK 재생성(정의는 REFERENCES (id) → 이제 UNIQUE(id) 대상). + FOR fk IN SELECT child, conname, def FROM _kx_fk_bak LOOP + EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I %s', fk.child, fk.conname, fk.def); + END LOOP; + END LOOP; +END $$; + +-- ============================================================================ +-- 3) 표준 선언(주석) — 신규 테넌트 소유 테이블 규약. +-- ============================================================================ +COMMENT ON TABLE tenant IS + '전시관(Venue) 테넌트 마스터 — 최상위 격리 스코프. KINTEX=테넌트 #1(표준 코드 대문자). ' + '표준: 신규 테넌트 소유 테이블은 생성 시부터 PRIMARY KEY (tenant_id, id) + tenant_id DEFAULT ''KINTEX''.'; +COMMENT ON COLUMN event.tenant_id IS '소속 테넌트 코드(선두 PK 컬럼). 표준 대문자 코드 — DEFAULT ''KINTEX''.'; +COMMENT ON COLUMN hall.tenant_id IS '소속 테넌트 코드(선두 PK 컬럼). 표준 대문자 코드 — DEFAULT ''KINTEX''.'; +COMMENT ON COLUMN app_user.tenant_id IS '소속 테넌트 코드(선두 PK 컬럼). 표준 대문자 코드 — DEFAULT ''KINTEX''.'; diff --git a/src/backend/src/main/resources/db/migration/V32__kintex_events_2026h2.sql b/src/backend/src/main/resources/db/migration/V32__kintex_events_2026h2.sql new file mode 100644 index 0000000..6c01610 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V32__kintex_events_2026h2.sql @@ -0,0 +1,138 @@ +-- V32 킨텍스 최신 행사 데이터 적재(크롤 2026 하반기, 34건) — 소유자 지시 2026-07-12 +-- 원천: kintex.com 크롤 (seq·title·hall·start·end·poster). 대부분 V10 시드에 (name,start_date)로 이미 존재 → +-- source_seq·poster_url 백필이 주 경로. 미존재분만 신규 INSERT. +-- 순증: event 에 poster_url·source_seq 컬럼(IF NOT EXISTS) + source_seq 부분 유니크(NULL 허용). +-- 멱등: 3단계 upsert 전부 재실행 안전(source_seq 클레임 가드). +-- 불변: V1~V31 불변. event 기존 컬럼(id·name·start_date·end_date·status·category·hall_label·tenant_id) 불변 — 컬럼 추가만. + +-- 1) 순증 컬럼 + 부분 유니크 인덱스 ------------------------------------------------ +ALTER TABLE event ADD COLUMN IF NOT EXISTS poster_url varchar(500); +ALTER TABLE event ADD COLUMN IF NOT EXISTS source_seq varchar(20); +COMMENT ON COLUMN event.poster_url IS '크롤 원천 포스터 이미지 URL(kintex.com imageView).'; +COMMENT ON COLUMN event.source_seq IS '크롤 원천 행사 시퀀스 키(중복 적재 방지 · 부분 유니크).'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_source_seq + ON event (source_seq) WHERE source_seq IS NOT NULL; + +-- 2) 크롤 스테이징(34건) — 트랜잭션 종료 시 자동 소멸 ------------------------------- +DO $$ +BEGIN + CREATE TEMP TABLE _kxw_stage ( + seq varchar(20), + title varchar(200), + hall_label varchar(120), + start_date date, + end_date date, + poster_url varchar(500) + ) ON COMMIT DROP; + + INSERT INTO _kxw_stage (seq, title, hall_label, start_date, end_date, poster_url) VALUES + ('2025044109', '대한민국 국제 관광박람회', '전시홀 10', DATE '2026-07-03', DATE '2026-07-05', 'https://www.kintex.com/imageView.do?atchmnflNo=468981&fileseq=1'), + ('26033102', '2026년 제5회 킨텍스 캠핑페스타', '전시홀 2', DATE '2026-07-03', DATE '2026-07-05', 'https://www.kintex.com/imageView.do?atchmnflNo=469112&fileseq=1'), + ('26030510', 'AI안전보건박람회(2026국제안전보건전시회)', '전시홀 7 , 전시홀 8', DATE '2026-07-06', DATE '2026-07-09', 'https://www.kintex.com/imageView.do?atchmnflNo=469220&fileseq=1'), + ('26031206', '제24회 국제 나노기술융합전시회', '전시홀 3 , 전시홀 4', DATE '2026-07-08', DATE '2026-07-10', 'https://www.kintex.com/imageView.do?atchmnflNo=469249&fileseq=2'), + ('26042803', '국제광융합엑스포 2026', '전시홀 2A', DATE '2026-07-08', DATE '2026-07-10', 'https://www.kintex.com/imageView.do?atchmnflNo=469230&fileseq=2'), + ('26051208', 'KDTEX 2026', '전시홀 7B , 전시홀 8A , 전시홀 8B', DATE '2026-07-17', DATE '2026-07-19', 'https://www.kintex.com/imageView.do?atchmnflNo=469185&fileseq=1'), + ('26041410', '2026 펫쇼코리아(하)', '전시홀 10', DATE '2026-07-24', DATE '2026-07-26', 'https://www.kintex.com/imageView.do?atchmnflNo=469147&fileseq=1'), + ('26033003', '2026 코베 베이비페어&유아교육전', '전시홀 7A-1 , 전시홀 7B-1', DATE '2026-08-06', DATE '2026-08-09', 'https://www.kintex.com/imageView.do?atchmnflNo=469133&fileseq=36'), + ('26052102', '2026 카페디저트페어', '전시홀 10A', DATE '2026-08-13', DATE '2026-08-16', 'https://www.kintex.com/imageView.do?atchmnflNo=469201&fileseq=3'), + ('26042207', 'K-PRINT 2026', '전시홀 7A , 전시홀 7B , 전시홀 8A , 전시홀 8B-1', DATE '2026-08-19', DATE '2026-08-22', 'https://www.kintex.com/imageView.do?atchmnflNo=469204&fileseq=1'), + ('26052702', '제71회 MBC건축박람회 외(통합명칭)', '전시홀 4 , 전시홀 5', DATE '2026-08-20', DATE '2026-08-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469219&fileseq=1'), + ('26031709', '2026 생활낚시박람회', '전시홀 10B', DATE '2026-08-21', DATE '2026-08-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469082&fileseq=1'), + ('26062208', '2026 월드 시티테크 엑스포', '전시홀 4', DATE '2026-08-26', DATE '2026-08-28', 'https://www.kintex.com/imageView.do?atchmnflNo=469254&fileseq=2'), + ('26012601', '한국국제가구 및 인테리어산업대전 (코펀)', '전시홀 7 , 전시홀 8', DATE '2026-08-27', DATE '2026-08-30', 'https://www.kintex.com/imageView.do?atchmnflNo=468958&fileseq=1'), + ('26061902', '마이펫페어 2026 일산 Part 2', '전시홀 10', DATE '2026-08-28', DATE '2026-08-30', 'https://www.kintex.com/imageView.do?atchmnflNo=469262&fileseq=1'), + ('26060205', '제19회 폐기물·자원순환산업전(RETECH) & 차세대 분체·유체기술전(A-POWDER TECH)', '전시홀 7', DATE '2026-09-02', DATE '2026-09-04', 'https://www.kintex.com/imageView.do?atchmnflNo=469256&fileseq=2'), + ('26040606', '이차전지 소재 · 부품 및 장비전 (K-BATTERY SHOW 2026)', '전시홀 7A-1 , 전시홀 7B-1', DATE '2026-09-09', DATE '2026-09-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469205&fileseq=1'), + ('26041504', '제6회 국제 운송 · 공급망관리 산업전', '전시홀 5', DATE '2026-09-09', DATE '2026-09-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469244&fileseq=1'), + ('26021906', '2026 로컬 콘텐츠 페스타', '전시홀 4', DATE '2026-09-18', DATE '2026-09-20', 'https://www.kintex.com/imageView.do?atchmnflNo=469246&fileseq=2'), + ('26033004', '2026 코베 베이비페어&유아교육전', '전시홀 10', DATE '2026-10-08', DATE '2026-10-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469129&fileseq=6'), + ('26032304', '2026 국제아웃도어캠핑&레포츠페스티벌 더 파이널 시즌 PART 1', '전시홀 7 , 전시홀 8', DATE '2026-10-09', DATE '2026-10-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469174&fileseq=1'), + ('26042903', '2026 도시안전기술박람회', '전시홀 1', DATE '2026-10-14', DATE '2026-10-16', 'https://www.kintex.com/imageView.do?atchmnflNo=469162&fileseq=9'), + ('26042904', '2026 한국건설·안전박람회', '전시홀 2', DATE '2026-10-14', DATE '2026-10-16', 'https://www.kintex.com/imageView.do?atchmnflNo=469163&fileseq=2'), + ('26030306', '2026 K-뷰티엑스포 코리아', '전시홀 3 , 전시홀 4 , 전시홀 5', DATE '2026-10-15', DATE '2026-10-17', 'https://www.kintex.com/imageView.do?atchmnflNo=469277&fileseq=2'), + ('26031015', '2026 TEX+VISION', '전시홀 10', DATE '2026-10-21', DATE '2026-10-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469060&fileseq=1'), + ('26070208', '국제 모빌리티 산업전', '전시홀 3', DATE '2026-10-21', DATE '2026-10-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469283&fileseq=1'), + ('26042905', '2026 국제 공구 및 스마트 용접 자동화전', '전시홀 2A , 전시홀 2B-1', DATE '2026-10-27', DATE '2026-10-30', 'https://www.kintex.com/imageView.do?atchmnflNo=469207&fileseq=1'), + ('26051112', '2026 솔라 아시아', '전시홀 7', DATE '2026-10-28', DATE '2026-10-30', 'https://www.kintex.com/imageView.do?atchmnflNo=469236&fileseq=1'), + ('26060404', '2026 카페&베이커리페어 시즌2', '전시홀 10', DATE '2026-10-29', DATE '2026-11-01', 'https://www.kintex.com/imageView.do?atchmnflNo=469247&fileseq=1'), + ('26030312', '2026 드론·도심항공모빌리티 박람회', '전시홀 2', DATE '2026-11-04', DATE '2026-11-06', 'https://www.kintex.com/imageView.do?atchmnflNo=469266&fileseq=1'), + ('26040310', 'WORLD HYDROGEN EXPO 2026 (H₂ MEET 2026)', '전시홀 6C , 전시홀 7 , 전시홀 8', DATE '2026-11-04', DATE '2026-11-06', 'https://www.kintex.com/imageView.do?atchmnflNo=469276&fileseq=1'), + ('26060202', '한국국제승강기엑스포 2026', '전시홀 5', DATE '2026-11-11', DATE '2026-11-13', 'https://www.kintex.com/imageView.do?atchmnflNo=469240&fileseq=1'), + ('26042204', '2026 메가주 일산 시즌2', '전시홀 2 , 전시홀 3 , 전시홀 4 , 전시홀 5', DATE '2026-11-20', DATE '2026-11-22', 'https://www.kintex.com/imageView.do?atchmnflNo=469186&fileseq=2'), + ('26043007', '2026 국제아웃도어캠핑&레포츠페스티벌 더 파이널 시즌 PART 2', '전시홀 8A , 전시홀 7', DATE '2026-11-27', DATE '2026-11-29', 'https://www.kintex.com/imageView.do?atchmnflNo=469177&fileseq=2'); + + -- 규칙 ① source_seq 일치 행 갱신(기간·홀·포스터). 재실행 시 최신 원천으로 리프레시. + UPDATE event e + SET start_date = s.start_date, + end_date = s.end_date, + hall_label = s.hall_label, + poster_url = s.poster_url + FROM _kxw_stage s + WHERE e.source_seq = s.seq; + + -- 규칙 ② (name, start_date) 일치 기존 행 백필(source_seq·poster_url·hall_label). + -- 미클레임(source_seq IS NULL) + 해당 seq 미사용 가드 → 멱등. + UPDATE event e + SET source_seq = s.seq, + poster_url = s.poster_url, + hall_label = s.hall_label + FROM _kxw_stage s + WHERE e.source_seq IS NULL + AND e.name = s.title + AND e.start_date = s.start_date + AND NOT EXISTS (SELECT 1 FROM event e2 WHERE e2.source_seq = s.seq); + + -- 규칙 ③ 그래도 미존재분 신규 INSERT — id='kxw-', tenant_id='KINTEX', category='전시', + -- status=기간 기준(종료<오늘→ended, 그 외→active: V10 컨벤션 active=진행/예정 통합). + INSERT INTO event (id, name, start_date, end_date, status, tenant_id, category, hall_label, poster_url, source_seq) + SELECT 'kxw-' || s.seq, s.title, s.start_date, s.end_date, + CASE WHEN s.end_date < CURRENT_DATE THEN 'ended' ELSE 'active' END, + 'KINTEX', '전시', s.hall_label, s.poster_url, s.seq + FROM _kxw_stage s + WHERE NOT EXISTS (SELECT 1 FROM event e WHERE e.source_seq = s.seq) + AND NOT EXISTS (SELECT 1 FROM event e WHERE e.name = s.title AND e.start_date = s.start_date) + ON CONFLICT (id) DO NOTHING; +END $$; + +-- ── 행사 상세 크롤 보강(2026-07-12 view.do 34건) — 주최/홈페이지/품목/소개/입장·관람 ── +ALTER TABLE event ADD COLUMN IF NOT EXISTS organizer varchar(300); +ALTER TABLE event ADD COLUMN IF NOT EXISTS official_site varchar(500); +ALTER TABLE event ADD COLUMN IF NOT EXISTS items varchar(600); +ALTER TABLE event ADD COLUMN IF NOT EXISTS description text; +ALTER TABLE event ADD COLUMN IF NOT EXISTS admission varchar(300); +COMMENT ON COLUMN event.official_site IS 'kintex.com 상세(view.do) 크롤 — 행사 공식 홈페이지'; +UPDATE event SET official_site = COALESCE(official_site, 'http://www.ktourismshow.com/'), items = COALESCE(items, 'KITS 지자체 홍보관, 국내/해외 관광 홍보관, 테마여행관, 여행정보관, B2B바이어상담회, KITS 어워드, 체험행사 등'), description = COALESCE(description, '*국내외 다양한 관광시장 동향 및 추세 공유를 통한 유익한 정보 습득 및 관련 체험 진행 *기업간 협업 기회를 모색하여 관광산업 관계자들의 네트워크 형성 *관광시장 확대 및 지역 관광 균형 발전을 위한 우수 관광 명소 홍보 - 대한민국 고향사랑기부 페스티벌 : 지역과 상생을 실천하는 고향사랑기부 문화 확산의 장 - 대한민국 귀농귀촌 행복박람회 : 농촌 정착 정보와 정책을 한자리에서 만나는 귀농·귀촌 종합 상담의 장') WHERE source_seq = '2025044109'; +UPDATE event SET official_site = COALESCE(official_site, 'www.campingfesta.com'), items = COALESCE(items, '캠핑차량, 캠핑용품, 캠핑소품, 캠핑서비스, 캠핑푸드'), description = COALESCE(description, '국내 캠핑관련 최신트렌드 제시, B2B 및 B2C를 통한 참가사 판로확보 지속 가능하고 안전한 캠핑문화 기반조성 및 계기마련 참가기업과 참관객의 NEEDS를 해소할 수 있는 Market Place 제공') WHERE source_seq = '26033102'; +UPDATE event SET official_site = COALESCE(official_site, 'www.safetyshow.co.kr'), items = COALESCE(items, '산업안전 및 보호구 분야 산업보건 및 직업건강 분야 방재산업 분야 공정안전관리 및 가스 산업 분야 실험실 안전관리 분야 스마트안전 분야 공공서비스 분야'), description = COALESCE(description, '안전보건 관련 국내·외 대표 브랜드가 참가해 최신 기술과 제품을 시연 320여개 사의 전문가들과의 상담을 통한 신기술 · 신제품 정보획득') WHERE source_seq = '26030510'; +UPDATE event SET official_site = COALESCE(official_site, 'https://nanokorea.or.kr/main/main.php'), items = COALESCE(items, 'ㅇ 나노기술을 기반 소재·부품·장비, 반도체, 바이오, 에너지, 센서, 레이저 등 첨단산업 전반의 기술과 제품 전시'), description = COALESCE(description, 'ㅇ 전시회, 컨퍼런스, NANO Biz Forum, Award, 1:1투자자상담회, 해외바이어 초청상담회 등') WHERE source_seq = '26031206'; +UPDATE event SET official_site = COALESCE(official_site, 'www.light-convergence.com'), items = COALESCE(items, 'LED & OLED 소부장, LIGHTING전시관, ICT융합 Display & Signage 전시관, 광융합 기술전시관'), description = COALESCE(description, '국내·외 다양한 광융합기술의 신제품과 기술력등을 한자리에 볼수있는 산업전시') WHERE source_seq = '26042803'; +UPDATE event SET official_site = COALESCE(official_site, 'http://kdtex.org/'), items = COALESCE(items, '학술대회 및 치과기자재 전시'), description = COALESCE(description, '* 지식의 향연, 학술강의 국내외 최고 권위자들이 전하는 최신 지식의 장입니다. 치과기공사의 패러다임을 바꿀 혁신적인 이론과 실전 노하우를 한자리에서 만나보세요. * 기술의 정점, 치과기자재 전시회 글로벌 치과 산업의 현재와 미래를 잇는 최첨단 장비와 재료들이 한곳에 모입니다. 디지털 덴티스트리의 정수를 직접 체험하고 비교해 보실 수 있습니다. * 예술과 기술의 조화, 보철작품 & 포스터 전시 단순한 기공물을 넘어 예술적 가치를 담아낸 회원 및 학생들의 수준 높은 보철 작품을 선보입니다. 연구 성과가 집약된 학술 포스터를 통해 최신 트렌드를 확인하세요. * 차세대 리더의 탄생, 학생 실기경진대회 미래의 치과기공계를 이끌어갈 인재들의 뜨거운 열정과 정교한 기술이 맞붙는 현장입니다. 예비 치과기공사들의 패기 넘치는 도전과 성장을 함께 응원해 주세요.') WHERE source_seq = '26051208'; +UPDATE event SET official_site = COALESCE(official_site, 'https://www.pet-show.co.kr/main'), items = COALESCE(items, '펫푸드(사료, 간식, 영양제, 의약품 등) / 펫용품(미용, 목욕, 야외, 놀이, 교육, 훈련, 위생, 배변, 건강관리 외 관련용품) / 가구&장비(하우스, 케이지, 미용대, 호텔장, 진열대, 캣타워, 드라이기 등) / 패션(의류, 액세서리, 소품, 홈 인테리어 등) / 서비스(병원, 교육 및 학습서비스, 헬스케어, 간호, 장례, 보험, 출판, IT 서비스 등) / 기타(협회 및 단체 매거진 등)'), description = COALESCE(description, '반려동물 용품 박람회') WHERE source_seq = '26041410'; +UPDATE event SET official_site = COALESCE(official_site, 'https://cobe.co.kr/__kintex.php'), items = COALESCE(items, '- 안전용품 : 유모차, 카시트, 아기띠, 보행기 등 - 임신출산 용품 : 태교용품, 수유용품, 스킨케어, 침구류, 의류, 의료기기, 건강보조식품, 목욕용품 등 - 영유아 용품 : 분유, 이유식, 유아전용생수, 친환경식품, 아토피식품, 건강보조식품, 의류, 가방 등 - 학습교육 : 도서출판물, 교육교재, 교육프로그램, 자습서, 육아잡지, 영어영재교육 등 - 영유아 서비스 : 제대혈은행, 태아보험, 베이비스튜디오, 돌잔치용품, 어린이병원, 소아과, 한의원, 여행사, 이벤트 기획사 등 - 임산부 서비스 : 산후조리원, 산후도우미, 산모체조교실, 산후마사지, 산부인과, 산모상담서비스 등 - 가전가구 : 유아가구, 아가방꾸미기, 친환경인테리어제품, 아가용가전 등 - 교육용 문구완구 : 지능계발완구, 팬시류, 악기류, 퍼즐블록, 조립식완구류, 미술용품, 캐릭터제품, 교육용 완구, 원목완구 등 - 정보제공기관 : 임신출산육아관련 정보기관(육아지원센터, 온라인커뮤니티, 어린이재단 등)'), description = COALESCE(description, '코베 베이비페어&유아교육전은 임신, 출산, 육아, 유아교육 관련 다양한 브랜드와 서비스를 한자리에서 만날 수 있는 국내 최대 규모 영유아 전문 전시회입니다. 아이를 위한 육아용품과 서비스를 직접 비교·체험하고 현장 상담까지 받을 수 있으며, 제품 구매 시 사용할 수 있는 코베페이 할인 혜택, 다양한 참여 이벤트도 함께 준비되어 있습니다. *더 자세한 이벤트 및 내용은 공식 홈페이지에서 확인 가능합니다.') WHERE source_seq = '26033003'; +UPDATE event SET official_site = COALESCE(official_site, 'www.cafedessertfair.com'), items = COALESCE(items, '커피(원두, 생두, 드립백 등), 디저트(케이크,마카롱,초콜릿,떡,약과 등), 머신(커피머신, 제빙기, 반죽기, 키오스크 등), 베이커리(생지, 완제품빵, 베이킹용품, 베이킹재료 등), 음료/주류(시럽, 청, 에이드, 와인, 수제맥주, 막걸리 등), 차(한국전통차, 홍차, 콤부차, 밀크티 등), 프랜차이즈(프랜차이즈 창업 컨설팅, 인테리어컨설팅, 납품 상담 등), 그린패키징(친환경 패키징, 종이컵, 포장재 등), 홈카페용품(가정용커피머신, 홈베이킹 용품, 커피추출기구 등), 인테리어(카페 인테리어 상담, 테이블웨어, 플레이트, 가구 등), 창업컨설팅(카페창업컨설팅, 장비설치상담, 원두납품 상담 등)'), description = COALESCE(description, '2026년 하반기 카페 산업의 핵심 트렌드를 가장 빠르게 만나볼 수 있는 전시회입니다. 8월, 여름 시즌 음료와 디저트는 물론, 다가오는 하반기 및 겨울 시즌 트렌드를 미리 경험할 수 있는 자리로 운영됩니다. 카페 운영자와 예비 창업자, 유통 바이어 및 일반 소비자들이 메뉴 개발 아이디어와 시즌 운영 인사이트를 얻을 수 있도록 구성되며, 커피·디저트·베이커리·음료·원부자재·장비·패키징 등 카페 산업 전반의 브랜드들이 참여해 시음·시식·체험·비즈니스 상담이 가능한 복합 F&B 플랫폼으로 운영됩니다.') WHERE source_seq = '26052102'; +UPDATE event SET official_site = COALESCE(official_site, 'www.kprint.kr'), items = COALESCE(items, '인쇄기기, 디지털인쇄기기, 평판인쇄기기, 라벨인쇄기기, 라벨 부착기, 후가공기기, 제본기기, 스크린인쇄기기, 라벨시스템, 바코드프린터, 제함기, 제지포장재료, 플로터, UV프린터, 전사프린터, 텍스타일프린터, CNC가공기, 목금형, 친환경 섬유, 소재 등'), description = COALESCE(description, '제29회 국제인쇄기자재회 KIPES 2026 한국레이블전시회 K-Label 2026 한국패키징전시회 K-PACK 2026 한국골판지·제지전시회 K-Corrugated 2026 한국디지털인쇄·솔루션전시회 K-Digital Print 2026 한국텍스타일전시회 K-Textile 2026 한국사인·광고전시회 K-Sign&AD 2026') WHERE source_seq = '26042207'; +UPDATE event SET official_site = COALESCE(official_site, 'www.dong-afairs.co.kr'), items = COALESCE(items, '건축 - 건축자재, 인테리어, 전원주택, 농촌체류형쉼터, 건축공구, 건축정보, 조경, 홈데코, 건축 및 주택관련 업체 냉난방 및 건축설비 - 냉난방기자재, 건축설비기자재, 신재생에너지,기타 냉난방관련제품 홈&리빙 - 가전, 주방용품, 홈인테리어, 인테리어 소품, 기타 생활용품 차·공예 - 차(茶), 도기류, 차(茶)관련 공예품, 차문화 및 생활용품 스포츠·레저·골프 - 헬스·휘트니스, 아웃도어용품, 캠핑용품, 레저차량, 골프, 기타 스포츠 및 골프관련 제품 SENDEX(재활&복지)-메디컬, 재활의료보조기기, 실버용품, 건강용품 및 식품, 기타 재활&복지관련 제품 추석명절선물 - 명절선물(전통상품, 식품, 음료, 주류, 건강상품), 선물용품, 판촉용품 등'), description = COALESCE(description, '이번 박람회는 제71회 MBC건축박람회 / 2026 서울 냉·난방 및 건축설비 박람회 / 제43회 동아 홈&리빙페어 / 2026 추석 명절 선물 박람회/ 제30회 동아 차·공예 박람회 / 제28회 동아 스포츠·레저·골프산업 박람회/ 2026 센덱스(재활&복지)박람회가 동시에 개최되는 행사로 관련산업의 다양한 신제품 및 최신 기술 정보, 업계 최신 트렌드를 한 눈에 보실 수 있습니다.') WHERE source_seq = '26052702'; +UPDATE event SET items = COALESCE(items, '낚시 관련 용품, 캠핑 및 레저용품, 캠핑카, 낚시보트, 아웃도어 등'), description = COALESCE(description, '● 국내 최초 루어낚시 전문박람회 동시개최 ● 캠핑특별관, 간편식특별관 동시개최 ● 낚시, 캠핑, 보트까지!') WHERE source_seq = '26031709'; +UPDATE event SET official_site = COALESCE(official_site, 'www.worldcitytechexpo.com'), items = COALESCE(items, '디지털 트윈, 도시재생, 재건축/재개발, 친환경 도시기술, 넷제로 에너지, 지능형 도시 기술 및 서비스, 스마트시티 인프라, 스마트 홈, 스마트 모빌리티, 스마트 정부, 스마트시티 서비스, 스마트빌리지 서비스 등'), description = COALESCE(description, '전시회, 컨퍼런스, 전문가 포럼, 해외바이어 초청 1:1 수출상담회, IR피칭 프로그램, 도슨트투어 등') WHERE source_seq = '26062208'; +UPDATE event SET official_site = COALESCE(official_site, 'www.kofurn.or.kr'), items = COALESCE(items, '가정용가구, 사무용가구, 산업용가구, 아트퍼니처, 조명&인테리어, 키친&테이블웨어, 홈네트워크&가전, 원부자재&목공기계'), description = COALESCE(description, '국내 유일의 국제 가구전시회 코펀KOFURN 전세계 15개국 350여개 가구 기업이 참가하여 1,350부스로 개최되며, 6만여명의 참관객이 방문하는 가구산업전시회로 산업 내 신제품 개발 및 기술혁신을 선보이는 전시회이다. 가구 제조, 원부자재, 유통, 디자인 등 가구산업을 총망라하여 펼쳐지는 4일간의 전시회는 국내 가구산업의 경쟁력을 강화하여 지속적인 산업의 성장 및 국제 사회로의 나아갈 길을 제시하고 있다.') WHERE source_seq = '26012601'; +UPDATE event SET items = COALESCE(items, '펫푸드 : 사료, 간식, 영양제 등 펫리빙 : 하우스, 매트, 식기, 위생/배변용품, 펫테리어 등 펫웨어 : 의류, 악세서리 등 펫케어 : 미용/목욕용품, 펫가전, 펫금융 건강관리 앱 등 펫아웃도어 : 유모차, 카시트, 하네스, 리드줄, 외출용품 등 펫플레이 : 장난감, 훈련/놀이용품 등 펫서비스 : 카페, 유치원,미용, 훈련, 여행, 펫시터, 펫택시, 장례 등'), description = COALESCE(description, '마이펫페어는 반려동물 관련 제품 및 브랜드가 참가하여 홍보 및 제품판매를 하는 B2C전시회와 더불어 반려동물과 함께 즐길 수 있는 [마이펫랜드]가 구성되어 반려견과 함께 축제처럼 즐길수 있는 전시회입니다. [마이펫랜드]에서는 반려견과 보호자가 함께하는 ‘도전하개,미로탈출’, ‘기다릴개’, ‘달려갈개’, ''킁킁놀이터'' ''맞춰볼개'' ''가져올개'' 등 체험이벤트로 반려동물과 함께 소중한 추억을 만들 수 있습니다.') WHERE source_seq = '26061902'; +UPDATE event SET official_site = COALESCE(official_site, 'https://www.re-tech.org/main/main.php'), items = COALESCE(items, '> 폐기물처리·자원재활용 기기·설비·기술·정책 > 지속가능 소재·원료, ESG·순환경제 솔루션 및 플랫폼 서비스 > 고부가가치 소재·원료 생산을 위한 분체기술 및 시스템 > 정밀 유체 제어 시스템과 지속 가능한 산업 환경을 위한 통합 설비'), description = COALESCE(description, '글로벌 전문 산업전시회와 컨퍼런스/세미나/전문 비즈니스 전시회') WHERE source_seq = '26060205'; +UPDATE event SET official_site = COALESCE(official_site, 'www.kbatteryshow.com'), items = COALESCE(items, '· 양극소재 · 음극소재 · 분리막 · 전해질 · 보호회로 · 집전장치 · 첨가제 · 자동화설비 · 기압환기구 · 전극포일 · 절연튜브 · 활성화탄소 · 이온액체 · 수소흡장합금 · 충전용 배터리 · 캐퍼시터 · 제조장비 · 검사/측정장비 · 기타 이차전지 소재 및 부품 · 기타 배터리'), description = COALESCE(description, 'K-BATTERY SHOW 2026은 양극소재, 음극소재, 분리막, 전해질 등 이차전지 대표 소재 외에 소형 이차전지, ESS전지, 모빌리티 전지 등 중대형 이차전지를 비롯하여, 차세대‘꿈의 배터리’로 불리는 전고체 배터리 기술 및 배터리 충전시스템, 인프라외에 리사이클링 시스템 등을 전시 및 소개 합니다.') WHERE source_seq = '26040606'; +UPDATE event SET official_site = COALESCE(official_site, 'www.scmfair.kr'), items = COALESCE(items, '● Digital Freight & End-to-End Solution 실시간 가시성 플랫폼 (RTVP), 디지털 포워딩 / LaaS, 통합 관제 및 관세 / 규제 솔루션 등 ● Optimizing Supply Chain AI 기반 예측 및 플래닝, 공급망 리스크 관리 (SCRM), 운송관리 시스템 (TMS), 창고관리 시스템 (WMS), 고도화 솔루션 등 ● Intelligence Robot & Automation 지능형 로봇 (AMR/AGV), 창고 자동화 설비 / 솔루션, 자율 운송 및 드론, AI 기반 머신비전 솔루션, 자동인식(AIDC) 솔루션 등 ● Equipment & Materials Handling 지게차, 팔레트 트럭, 랙 / 선반 시스템, 컨테이너 크레인, 스태커, 카고트럭 (상용차), 벌크 컨테이너 시스템, 갠트리 크레인, 컨베이어, 랩핑기, 모터 / 감속기 / 배터리, 베어링, 유압 / 공압 부품, 케이블 및 하네스 등 ● Digital Retail & Las'), description = COALESCE(description, 'SCM FAIR 2026은 디지털 포워딩부터 AI SCM 솔루션, 지능형 로봇, 친환경 포장 및 최신 하드웨어 장비까지 공급망 전 주기를 총망라합니다. 공급 기업과 수요(화주) 기업이 한자리에 모이는 최적의 비즈니스 플랫폼으로서, 격변하는 세계 시장 속 우리 기업의 글로벌 경쟁력을 극대화할 명확한 비전과 방향성을 제시합니다.') WHERE source_seq = '26041504'; +UPDATE event SET items = COALESCE(items, '전국 우수 지자체 로컬 콘텐츠 홍보 (광역, 기초, 유관기관 등) 우수 로컬 푸드 홍보 및 판매'), description = COALESCE(description, '개막식 (KBC 광주방송, 지방자치TV, 유튜브 라이브) 전국 우수 지자체 로컬콘텐츠 전시 지자체 고향사랑기부금 전달식 추석맞이 우수 로컬 푸드 홍보 및 판매 인기 가수 축하 공연 (기간 중 매일)') WHERE source_seq = '26021906'; +UPDATE event SET official_site = COALESCE(official_site, 'https://cobe.co.kr/__kintex.php'), items = COALESCE(items, '- 안전용품 : 유모차, 카시트, 아기띠, 보행기 등 - 임신출산 용품 : 태교용품, 수유용품, 스킨케어, 침구류, 의류, 의료기기, 건강보조식품, 목욕용품 등 - 영유아 용품 : 분유, 이유식, 유아전용생수, 친환경식품, 아토피식품, 건강보조식품, 의류, 가방 등 - 학습교육 : 도서출판물, 교육교재, 교육프로그램, 자습서, 육아잡지, 영어영재교육 등 - 영유아 서비스 : 제대혈은행, 태아보험, 베이비스튜디오, 돌잔치용품, 어린이병원, 소아과, 한의원, 여행사, 이벤트 기획사 등 - 임산부 서비스 : 산후조리원, 산후도우미, 산모체조교실, 산후마사지, 산부인과, 산모상담서비스 등 - 가전가구 : 유아가구, 아가방꾸미기, 친환경인테리어제품, 아가용가전 등 - 교육용 문구완구 : 지능계발완구, 팬시류, 악기류, 퍼즐블록, 조립식완구류, 미술용품, 캐릭터제품, 교육용 완구, 원목완구 등 - 정보제공기관 : 임신출산육아관련 정보기관(육아지원센터, 온라인커뮤니티, 어린이재단 등)'), description = COALESCE(description, '코베 베이비페어&유아교육전은 임신, 출산, 육아, 유아교육 관련 다양한 브랜드와 서비스를 한자리에서 만날 수 있는 국내 최대 규모 영유아 전문 전시회입니다. 아이를 위한 육아용품과 서비스를 직접 비교·체험하고 현장 상담까지 받을 수 있으며, 제품 구매 시 사용할 수 있는 코베페이 할인 혜택, 다양한 참여 이벤트도 함께 준비되어 있습니다. *더 자세한 이벤트 및 내용은 공식 홈페이지에서 확인 가능합니다.') WHERE source_seq = '26033004'; +UPDATE event SET official_site = COALESCE(official_site, 'www.caf.co.kr'), items = COALESCE(items, '캠핑장비, 캠핑차량, 캠핑푸드, 아웃도어 용품, 레저스포츠 용품, 안전용품, 관련 서비스 등'), description = COALESCE(description, '고카프는 캠핑∙레포츠 관련 용품, 장비, 서비스, 푸드, 테크까지 모두 관람할 수 있는 박람회 입니다.') WHERE source_seq = '26032304'; +UPDATE event SET items = COALESCE(items, '* 기후위기 대응 기술(기후·재난, 홍수·산불·지진·폭염, 자연재난 대응, 재난 시뮬레이션 및 예측) * 도시위기 대응 기술(사회재난, 도시위기, 화재·감염병·대형사고, 위기관리, 공공재난 대응) * 인프라 관리 기술(도시안전 플랫폼, 교통안전, 통합 관제) * 취약계층 보호 기술(생활안전, 시민 체감 안전, 고령자·취약계층, 주거·공공시설 안전)'), description = COALESCE(description, '* 국내 최대규모 ‘재난안전’분야 바이어 방문 전시회 * 시민의 일상을 지키는 ‘도시안전’분야 국내유일 특화 전시회 * 전시회+ 프로그램을 통한 관계자 네트워킹&거래실적 창출하는 전시회') WHERE source_seq = '26042903'; +UPDATE event SET official_site = COALESCE(official_site, 'https://k-consafetyexpo.com/'), items = COALESCE(items, 'AI·디지털 기반 건설현장 지능화 솔루션(스마트건설) AI 기반 사고 예측·위험관리 솔루션(스마트안전/ 출입통제·보안) 추락·붕괴 사고 예방 구조 안전 솔루션(가설기자재/안전장비) 근로자 행동·상태 인식 안전 솔루션(개인보호구/건설현장안전) 중장비·이동체 충돌 사고 예방 솔루션(건설장비) 화재·폭발·유해물질 사고 예방 기술(화재예방감지기/장치) 재난·비상 대응 로봇·드론 솔루션(재난대비건축재) VR·XR 기반 안전교육·훈련 솔루션(안전교육) 공공·발주처 안전관리 및 ESG 플랫폼(공공부문)'), description = COALESCE(description, '● 첨단기술이 적용된 융복합 첨단안전산업 제품 및 기술 특별관 구축 ● 한국건설가설협회, 한국건설안전회 등 관련 기관 및 협단체 컨퍼런스 개최') WHERE source_seq = '26042904'; +UPDATE event SET official_site = COALESCE(official_site, 'www.kbeautyexpo.com'), items = COALESCE(items, '화장품, 헤어, 네일, 에스테틱, 바디, 스마트뷰티, 이너뷰티, OEM/ODM, 원료, 패키징, 의료서비스 등'), description = COALESCE(description, '2026년 18회차를 맞이한 K-BEAUTY EXPO KOREA는 국내 최대 규모의 B2B 뷰티전문 산업박람회입니다. 해외바이어 초청 1:1 매칭 수출상담회, 자비참관 해외바이어 프리 매칭, 국내 주요 유통업체 MD초청 구매상담회, 전문 컨퍼런스 및 세미나 등 다양한 B2B 프로그램과 더불어 참가기업의 제품 홍보를 위한 BeautyFull 사회공헌행사 등 다채로운 B2C 프로모션까지, K-BEAUTY EXPO KOREA에서 만나보세요.') WHERE source_seq = '26030306'; +UPDATE event SET official_site = COALESCE(official_site, 'www.texvision.co.kr'), items = COALESCE(items, '●Sustainability Lab : 섬유 재활용 및 업사이클링 소재, 바이오 기반 섬유 소재, 친환경 염색/가공 및 디지털 프린팅 기술, 인증/검사 및 공정 관리 솔루션 등 ●Industry & Tech Zone : 의료/헬스케어 기능성 섬유, 건축/인테리어 기능성 패브릭, 차량용 고기능 섬유 소재, 농업/환경 인프라용 섬유 ●Lifestyle & Performance Zone : 스포츠/아웃도어 기능성 섬유, 스마트/웨어러블 섬유 제품, 기능성 침구 및 리커버리웨어, 위생/방수/방오 등 생활 기능성 섬유'), description = COALESCE(description, '전시회, 해외바이어 초청 수출상담회, 섬유산업 컨퍼런스, 혁신기술 어워즈, 신기술 쇼케이스 등') WHERE source_seq = '26031015'; +UPDATE event SET official_site = COALESCE(official_site, 'www.koaashow.com'), items = COALESCE(items, '이모빌리티, Mechanism/HMI 부품, 지능형 외장 시스템, 융합전장 시스템, 다목적 소재, 케미칼, 액세서리, 시험기기, 제조설비, 소프트웨어, EV 충전솔루션, 정비, 신기술 등'), description = COALESCE(description, 'ㅇ 제품 및 기술 전시 : 참가기업의 제품 및 기술을 국내외 참관객에게 선보입니다. ㅇ 비즈니스 매칭 : 참가기업과 해외바이어의 관심품목을 사전 조율하여 1:1 구매상담회를 개최합니다. ㅇ 컨퍼런스 : 모빌리티 산업 동향과 최신 기술 트렌드를 소개합니다.') WHERE source_seq = '26070208'; +UPDATE event SET official_site = COALESCE(official_site, 'www.tool-welding.com'), items = COALESCE(items, '- 공구(절삭공구, 다이아몬드공구, 연마/금형공구, 전동 및 공기압공구, 작업공구, 측정공구 등) - 용접(스마트용접기, 용접검사장비, 용접자동화, 레이저절단기기, 용접부품 및 주변기기, 용접 재료) - 관련제품(공구함, 조경/배관/목공공구, 운반하역기기, 산업안전 및 보호구, 다이캐스팅 재료/ 금형/설비, 기타 산업용재)'), description = COALESCE(description, '가정용부터 산업용까지 모든 종류의 공구 및 용접 관련 제품이 총망라되는 전시회 - 소부장뿌리기술대전, 금속산업대전 등 연관 전시회와 합동 개최 - 공구 정밀가공기술 / 용접 기술 관련 세미나, AR 시뮬레이터 용접 기능경기대회 등 부대행사 진행 예정') WHERE source_seq = '26042905'; +UPDATE event SET official_site = COALESCE(official_site, 'https://www.solarasia.co.kr/'), items = COALESCE(items, '태양광 셀&모듈, 태양광 생산장비, 태양광 전력 및 발전설비, 태양광 구조물 및 설치시스템, 태양광 구조물 및 설치시스템, 태양전지 평가·테스트·분석장비, 태양광 시스템 설치·SI·O&M 서비스, 태양광 소재 및 부품, 정부 지원 및 정책 홍보'), description = COALESCE(description, '• 부대행사: 탄소중립산업포럼, PV월드포럼, 배터리테크포럼 외 관련 세미나 등') WHERE source_seq = '26051112'; +UPDATE event SET official_site = COALESCE(official_site, 'https://www.cafenbakeryfair.com'), items = COALESCE(items, '커피, 베이커리, 디저트, 음료·주류, 시럽, 원부재료, 카페 장비, 바리스타 용품, 주방·포장용품, 프랜차이즈, 인테리어, 가구 및 소품 등'), description = COALESCE(description, '국내 대표 커피 비즈니스 플랫폼, 카페&베이커리페어 2012년 첫 개최 이후 꾸준히 성장해 온 카페&베이커리페어는 커피·베이커리를 비롯한 다양한 F&B 제품과 브랜드, 최신 트렌드를 소개하는 전문 전시회입니다. 참가기업에게는 브랜드 홍보와 판로 확대의 기회를, 바이어와 예비 창업자에게는 새로운 비즈니스와 창업 정보를, 일반 참관객에게는 다양한 커피와 디저트, F&B 제품을 직접 체험하고 구매할 수 있는 기회를 제공합니다. ■ 카페&베이커리페어 주요 구성 및 특별관 ▲ 창업 장비 특별관 창업과 매장 운영에 필요한 장비 및 솔루션 전문관 ▲ 스페셜티 원두 특별관 ''로스터리비'' 스페셜티 커피와 로스터리 브랜드 전문관 ▲ 차 특별관 ''티빌리지'' 차(Tea), 티푸드, 다기 등 다양한 티(Tea) 전문관 ▲ 주류 특별관 ''코리아스피릿페스타'' 전통주, 와인, 위스키, 맥주 등 다양한 주류 브랜드 전문관 ▲ 마이프차 프랜차이즈 창업박람회 동시 개최 카페·외식 프랜차이즈를 비롯한 다양한 창업 브랜') WHERE source_seq = '26060404'; +UPDATE event SET official_site = COALESCE(official_site, 'https://www.droneuamexpo.kr/ko'), items = COALESCE(items, 'ㅇ 항공 무인이동체: 무인항공기, 드론 부품·소재, 활용 서비스, 데이터 수집·분석 기술 등 ㅇ 도심항공교통(UAM): UAM 시뮬레이션, 버티포트, 교통 관리 시스템(UTM), 운항 제어 솔루션 등'), description = COALESCE(description, '2026 드론·도심항공모빌리티 박람회는 국내외 첨단 드론 및 UAM(도심항공교통) 분야의 혁신 기술과 최신 제품을 한눈에 조망하는 전문 비즈니스 전시회입니다. 글로벌 선도 기업들이 대거 참가하여 다채로운 솔루션을 선보이며, 비즈니스 성과 창출을 위한 1:1 수출 상담회, 전문가 세미나 및 워크숍, 대중 참여형 드론 체험 및 시연 행사 등 다각적인 프로그램이 동시 개최됩니다.') WHERE source_seq = '26030312'; +UPDATE event SET official_site = COALESCE(official_site, 'www.h2meet.com'), items = COALESCE(items, '수소 생산 분야 (H₂ Production) · 수전해, 탄소포집, 신재생에너지(풍력, 수력, 원자력 등) 수소 저장 · 운송 분야 (H₂ Storage & Distribution) · 수소충전소, 수소탱크, 액화/기체, 파이프라인, 운송(해상, 육상), 안전 등 수소 활용 분야 (H₂ Utilization) · 모빌리티, 연료전지, 서비스, 철강, 화학 등 관련 산업 기관 및 단체 (Institute) · 국내 지자체, 유관기관, 단체 및 해외 국가관, 대사관, 무역투자대표부 등'), description = COALESCE(description, '행사구성 · 전시회, 국제컨퍼런스 및 포럼, 세미나, 학술대회 등 부대행사 · H₂ Global Award, Country Day, H₂ 글로벌 비즈니스 파트너십 페어, 리셉션, 투어 등') WHERE source_seq = '26040310'; +UPDATE event SET official_site = COALESCE(official_site, 'www.liftexpokorea.com'), items = COALESCE(items, '▶ 승강기 완제품 엘리베이터,에스컬레이터,휠체어리프트 등 ▶ 승강기 부품 권상기,제어반,가이드레일,테스트 장비,카 판넬, 디스플레이 제품 등 ▶ 승강기 관련 시스템 자율주행로봇 연동시스템,관제시스템,보안시스템,방재시스템,점검시스템 등 ▶ 승강기 설치 및 유지관리 장비 호이스트,안전장비,소음측정장비, 열화상카메라, 영상장비 등'), description = COALESCE(description, '승강기 산업 전문 전시회') WHERE source_seq = '26060202'; +UPDATE event SET official_site = COALESCE(official_site, 'https://megazoo.co.kr/'), items = COALESCE(items, '■ 사료 : 건사료, 습식사료, 수제사료, 생식, 화식 등 ■ 간식 : 동결건조, 저키, 캔, 파우치, 수제간식, 스틱간식 등 ■ 헬스케어 : 영양제, 보조제, 의료기기, 검사키트, 수의상담 등 ■ 외출&놀이용품 : 장난감, 노즈워크, 하네스, 리드줄, 유모차, 카시트, 인식표, 산책용품 등 ■ 목욕&미용용품 : 샴푸, 세정제, 보습제, 스파, 칫솔, 타월, 드라이룸, 미용기기 등 ■ 리빙용품 : 식기, 방석, 계단, 하우스, 매트, 급식기, 급수기 등 ■ 의류&악세서리 : 옷, 악세서리 등 ■ 서비스/기타 : 호텔, 장례, 보험, 스튜디오, 애플리케이션, 매거진 등'), description = COALESCE(description, '■ 반려동물 사료&간식, 용품 판매 ■ 글로벌 B2B 존 : 글로벌 파빌리온, 비즈니스라운지, 비즈매칭 상담회 ■ 부대행사 : 쇼케이스, 체험 클래스, 세미나 등 * 참관료 안내 ■ 1차 사전등록 : 무료 ■ 2차 사전등록 : 6,000원 ■ 현장등록 : 12,000원 ■ 무료입장 대상 : 반려동물산업 종사자/1961년생 및 이전 출생자(66세 이상)/2011년생 및 이후 출생자(16세 이하)/장애인(복지카드 소지자)/국가 유공자(신분증 소지자)/강아지 헌혈증 소지자 본인(1인)/프레스/외국인') WHERE source_seq = '26042204'; +UPDATE event SET official_site = COALESCE(official_site, 'www.caf.co.kr'), items = COALESCE(items, '캠핑장비, 캠핑차량, 캠핑푸드, 아웃도어 용품, 레저스포츠 용품, 안전용품, 관련 서비스 등'), description = COALESCE(description, '고카프는 캠핑∙레포츠 관련 용품, 장비, 서비스, 푸드, 테크까지 모두 관람할 수 있는 박람회 입니다.') WHERE source_seq = '26043007'; diff --git a/src/backend/src/main/resources/db/migration/V33__m8_logistics_ext.sql b/src/backend/src/main/resources/db/migration/V33__m8_logistics_ext.sql new file mode 100644 index 0000000..6c3f216 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V33__m8_logistics_ext.sql @@ -0,0 +1,141 @@ +-- ===================================================================== +-- V33 M8 물류 확장 — F041 지게차·부대장비 신청 / F042 부대물품 렌탈 주문 / F043 선입고 트래킹 +-- 멱등: CREATE TABLE IF NOT EXISTS + 시드 ON CONFLICT DO NOTHING. +-- 기존 M8(V15 dock·dock_reservation) 불변 — 본 파일은 V33 단일 버전만 사용, V1~V32 및 타 팀 마이그레이션 변경 금지. +-- 멀티테넌시 표준(소유자 지시 2026-07-12): tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX' +-- + PRIMARY KEY (tenant_id, id) (+ FK 대상은 UNIQUE(id) 보존). +-- 금액은 서버 권위: 장비=단가×수량×일수 / 렌탈=일단가×수량×기간(일). 카탈로그 단가는 마스터가 정본. +-- ===================================================================== + +-- ── ① 부대장비 품목 마스터(지게차·리프트·크레인·인력 등) — F041 ────────────── +CREATE TABLE IF NOT EXISTS logistics_equipment ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + code varchar(40) NOT NULL, -- 표시/조회용 품목코드 + name varchar(120) NOT NULL, + category varchar(20) NOT NULL DEFAULT 'forklift', -- forklift|lift|crane|manpower|tool|other + unit varchar(20) NOT NULL DEFAULT '대/일', -- 과금 단위 표기 + unit_price bigint NOT NULL DEFAULT 0, -- 단가(원) — 서버 금액 계산 정본 + spec varchar(200), -- 규격/비고(예: 2.5t 지게차) + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + UNIQUE (tenant_id, code), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_logi_equip_scope ON logistics_equipment (tenant_id, category, active); + +-- ── 장비 신청(부스별) — requested→approved/rejected(매니저)→done ────────────── +CREATE TABLE IF NOT EXISTS logistics_equipment_request ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + equipment_id varchar(40) NOT NULL, -- logistics_equipment.id 소프트 참조(스냅샷 병행) + equipment_name varchar(120) NOT NULL, -- 신청 시점 품목명 스냅샷 + unit_price bigint NOT NULL DEFAULT 0, -- 신청 시점 단가 스냅샷 + booth_no varchar(40), + quantity integer NOT NULL DEFAULT 1, + use_date date, -- 사용 예정일 + days integer NOT NULL DEFAULT 1, -- 사용 일수 + amount bigint NOT NULL DEFAULT 0, -- 서버 계산 = unit_price×quantity×days + status varchar(20) NOT NULL DEFAULT 'requested',-- requested|approved|rejected|done + note varchar(300), + reject_reason varchar(300), + requested_by varchar(40), + decided_by varchar(40), + decided_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_logi_equip_req_scope ON logistics_equipment_request (tenant_id, event_id, status); + +-- ── ② 부대물품 렌탈 카탈로그 — F042 ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS logistics_rental_item ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + code varchar(40) NOT NULL, + name varchar(120) NOT NULL, + category varchar(20) NOT NULL DEFAULT 'furniture', -- furniture|electric|display|av|decor|other + unit varchar(20) NOT NULL DEFAULT '개/일', + daily_price bigint NOT NULL DEFAULT 0, -- 1일 렌탈 단가(원) + spec varchar(200), + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + UNIQUE (tenant_id, code), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_logi_rental_item_scope ON logistics_rental_item (tenant_id, category, active); + +-- ── 렌탈 주문 — ordered→confirmed/cancelled(매니저)→delivered ────────────── +CREATE TABLE IF NOT EXISTS logistics_rental_order ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + item_id varchar(40) NOT NULL, -- logistics_rental_item.id 소프트 참조 + item_name varchar(120) NOT NULL, -- 주문 시점 품목명 스냅샷 + daily_price bigint NOT NULL DEFAULT 0, -- 주문 시점 일단가 스냅샷 + booth_no varchar(40), + quantity integer NOT NULL DEFAULT 1, + start_date date, + end_date date, + days integer NOT NULL DEFAULT 1, -- 렌탈 기간(일) — 서버 산출 + amount bigint NOT NULL DEFAULT 0, -- 서버 계산 = daily_price×quantity×days + status varchar(20) NOT NULL DEFAULT 'ordered', -- ordered|confirmed|cancelled|delivered + note varchar(300), + ordered_by varchar(40), + decided_by varchar(40), + decided_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_logi_rental_order_scope ON logistics_rental_order (tenant_id, event_id, status); + +-- ── ③ 배송·창고 선입고 트래킹 — F043 ─────────────────────────────────────── +-- 상태 전이: registered → inbound_scheduled → arrived_warehouse → delivered_booth +CREATE TABLE IF NOT EXISTS logistics_inbound ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + tracking_no varchar(60) NOT NULL, -- 운송장 번호 + carrier varchar(80), -- 운송사 + booth_no varchar(40), + item_desc varchar(200), -- 화물 내용 + quantity integer NOT NULL DEFAULT 1, + status varchar(24) NOT NULL DEFAULT 'registered',-- registered|inbound_scheduled|arrived_warehouse|delivered_booth + eta_date date, -- 입고 예정일 + warehouse_at timestamptz, -- 창고 도착 시각 + delivered_at timestamptz, -- 부스 전달 시각 + note varchar(300), + registered_by varchar(40), + updated_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_logi_inbound_scope ON logistics_inbound (tenant_id, event_id, status); +CREATE INDEX IF NOT EXISTS idx_logi_inbound_track ON logistics_inbound (tenant_id, event_id, tracking_no); + +-- ── 시드: 부대장비 마스터 ────────────────────────────────────────────────── +INSERT INTO logistics_equipment (id, code, name, category, unit, unit_price, spec) VALUES + ('leq-fork-25', 'FORK-2.5T', '지게차 2.5t', 'forklift', '대/일', 180000, '2.5t 디젤 지게차(기사 포함)'), + ('leq-fork-05', 'FORK-5T', '지게차 5t', 'forklift', '대/일', 260000, '5t 디젤 지게차(기사 포함)'), + ('leq-lift-01', 'LIFT-1', '고소작업 리프트', 'lift', '대/일', 150000, '시저 리프트 최대 10m'), + ('leq-crane-5', 'CRANE-5T', '이동식 크레인 5t', 'crane', '대/일', 420000, '카고 크레인 5t'), + ('leq-manp-01', 'MANPOWER', '상하차 인력', 'manpower', '인/일', 90000, '숙련 상하차 인력 1인') +ON CONFLICT (id) DO NOTHING; + +-- ── 시드: 렌탈 카탈로그 ──────────────────────────────────────────────────── +INSERT INTO logistics_rental_item (id, code, name, category, unit, daily_price, spec) VALUES + ('lri-table-01', 'TBL-STD', '접이식 테이블', 'furniture', '개/일', 8000, '1800×600 접이식'), + ('lri-chair-01', 'CHR-STD', '상담용 의자', 'furniture', '개/일', 3000, '패브릭 상담 의자'), + ('lri-info-01', 'INFO-DESK','인포메이션 데스크', 'furniture', '개/일', 25000, '곡면 안내 데스크'), + ('lri-tv-55', 'TV-55', '55인치 모니터', 'av', '대/일', 60000, '55" UHD 거치형'), + ('lri-spot-01', 'SPOT-LED', 'LED 스포트 조명', 'electric', '개/일', 5000, '20W LED 스포트'), + ('lri-shelf-01', 'SHELF', '진열 선반', 'display', '개/일', 12000, '4단 진열 선반') +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V34__m9_refund_tax.sql b/src/backend/src/main/resources/db/migration/V34__m9_refund_tax.sql new file mode 100644 index 0000000..e5d0e1a --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V34__m9_refund_tax.sql @@ -0,0 +1,67 @@ +-- 킨텍스 — M9 확장: F085 환불·취소 규정 + F083 세금계산서 (멱등) +-- FEATURE_GAP: +-- F085 환불·취소 = 취소 시점 D-일(행사 개막일 기준) 구간별 환불율(룰셋 rulesets/refund-v1.0.json). +-- 청구 취소 시 status='cancelled' + 환불 레코드(invoice_refund) 기록. 실 PG 환불 없음(G2 재무 게이트). +-- F083 세금계산서 = 청구서 기반 세금계산서 데이터 문서(tax_invoice) 발행·조회. 국세청 전송 없음(G2 게이트). +-- 보안 불변: 본 마이그레이션은 V34 단독. V1~V33 및 타 팀 마이그레이션을 변경하지 않는다. 전부 멱등. +-- 멀티테넌시 표준(소유자 지시 2026-07-12): 신규 테넌트 소유 테이블은 tenant_id DEFAULT 'KINTEX' + PK (tenant_id, id) + UNIQUE(id). + +-- 1) invoice 취소 메타 순증 (F085) — 기존 상태에 'cancelled' 추가(체크 제약 없음, 값만 확장) ---- +ALTER TABLE invoice ADD COLUMN IF NOT EXISTS cancelled_at timestamptz; +ALTER TABLE invoice ADD COLUMN IF NOT EXISTS cancel_reason varchar(500); +COMMENT ON COLUMN invoice.cancelled_at IS 'F085 취소 확정 시각(status=cancelled)'; +COMMENT ON COLUMN invoice.cancel_reason IS 'F085 취소 사유(표시용, 민감정보 아님)'; + +-- 2) 환불 레코드(InvoiceRefund) — 청구 취소당 환불 산정 근거 1건 (F085) -------------------- +-- base_amount = 실 납입액(paid_amount) 기준. refund = base × rate/100, forfeit = base − refund. +-- status='recorded' = 산정·기록만(PG 미집행). 후속 재무 집행 시 'settled' 전환 여지. +CREATE TABLE IF NOT EXISTS invoice_refund ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + invoice_id varchar(40) NOT NULL REFERENCES invoice(id) ON DELETE CASCADE, + event_id varchar(40) NOT NULL, + base_amount bigint NOT NULL DEFAULT 0, -- 환불 산정 기준(실 납입액) + refund_rate_percent numeric(5,2) NOT NULL DEFAULT 0, -- 적용 환불율(%) + refund_amount bigint NOT NULL DEFAULT 0, -- 환불액 = base × rate/100 + forfeit_amount bigint NOT NULL DEFAULT 0, -- 공제·위약액 = base − refund + days_before_event integer, -- 취소 시점 D-일(음수=행사 후, null=개막일 미정) + bracket_label varchar(80), -- 적용 구간 라벨 + reason varchar(500), + ruleset_version varchar(40), -- refund-v1.0 + status varchar(20) NOT NULL DEFAULT 'recorded', -- recorded(기록·PG 미집행) | settled(후속) + created_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_invoice_refund_invoice ON invoice_refund (invoice_id); +CREATE INDEX IF NOT EXISTS idx_invoice_refund_event ON invoice_refund (event_id); +COMMENT ON TABLE invoice_refund IS 'F085 청구 취소·환불 산정 레코드 — 규정(refund-v1.0) 기준 서버 산정. 실 PG 환불 없음(G2 게이트)'; + +-- 3) 세금계산서(TaxInvoice) — 청구서당 1건(멱등). 공급자=킨텍스 상수, 공급받는자=참가업체 (F083) ---- +-- supplier_biz_no·supplier_ceo 는 재무 확정 전 플레이스홀더(G2). 국세청 전송 없음(status=issued=문서 발행). +CREATE TABLE IF NOT EXISTS tax_invoice ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + invoice_id varchar(40) NOT NULL REFERENCES invoice(id) ON DELETE CASCADE, + event_id varchar(40) NOT NULL, + tax_invoice_no varchar(40) NOT NULL, + supplier_name varchar(200) NOT NULL, -- 공급자(킨텍스) + supplier_biz_no varchar(20), -- 사업자등록번호(재무 확정 전 플레이스홀더) + supplier_ceo varchar(60), -- 대표자(재무 확정 전 플레이스홀더) + supplier_addr varchar(300), + buyer_name varchar(200) NOT NULL, -- 공급받는자(참가업체 표시명) + supply_amount bigint NOT NULL DEFAULT 0, -- 공급가액(invoice.subtotal 스냅샷) + vat bigint NOT NULL DEFAULT 0, + total bigint NOT NULL DEFAULT 0, + item_summary varchar(300), + status varchar(20) NOT NULL DEFAULT 'issued', -- issued(문서 발행·국세청 미전송) | void + issued_at timestamptz NOT NULL DEFAULT now(), + issued_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + UNIQUE (invoice_id), -- 청구서당 세금계산서 1건(멱등) + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_tax_invoice_event ON tax_invoice (event_id); +COMMENT ON TABLE tax_invoice IS 'F083 세금계산서 데이터 문서 — 청구서 기반. 국세청 전자세금계산서 전송은 별도(G2 게이트)'; diff --git a/src/backend/src/main/resources/db/migration/V35__webhook_automation.sql b/src/backend/src/main/resources/db/migration/V35__webhook_automation.sql new file mode 100644 index 0000000..f07d9f2 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V35__webhook_automation.sql @@ -0,0 +1,107 @@ +-- 킨텍스 — F099 API·웹훅 게이트웨이(인바운드 한정) + F052 리드 팔로업 EDM 자동 트리거 (멱등) +-- 리스크 G4 정합(FEATURE_GAP §7-2·§보안 불변): 외부 API 금지 원칙 — +-- ① 아웃바운드 발송은 스텁(발송 큐 레코드 기록까지 · 실 HTTP 콜 disabled) +-- ② 인바운드 수신만 실동작(토큰 + HMAC-SHA256 서명 이중 검증 후 저장). +-- 시크릿은 응답 미노출. 아웃바운드 구독 시크릿은 SHA-256 해시 저장(발송 disabled → 원문 불필요), +-- 인바운드 시크릿은 AES-256-GCM 봉인 저장(SecretCipher) — HMAC 검증은 원문이 필요하기 때문(가역). +-- 보안 불변: 본 마이그레이션은 V35 단독. V1~V34 및 타 팀 마이그레이션(visitor/cms/marketing)을 변경하지 않는다. +-- 멀티테넌시 표준(소유자 지시 2026-07-12): tenant_id = PK 선두 복합키 + DEFAULT 'KINTEX'. + +-- ── ① 아웃바운드 웹훅 구독 등록(관리자 CRUD, @Audited) ───────────────────────── +-- event_type(lead.hot·invoice.paid·auction.awarded 등)별 콜백 URL + 시크릿(해시) 등록. +-- 실 발송은 disabled(스텁) — webhook_delivery 에 큐 레코드만 남긴다. +CREATE TABLE IF NOT EXISTS webhook_subscription ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_type varchar(60) NOT NULL, -- lead.hot|invoice.paid|auction.awarded ... + callback_url varchar(500) NOT NULL, -- 표시/전달용(발송 disabled — 실 콜 없음) + secret_hash varchar(120) NOT NULL, -- SHA-256(secret) hex — 원문 미저장·미노출 + description varchar(300), + active boolean NOT NULL DEFAULT true, + created_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) -- ★표준: tenant_id 선두 복합 PK +); +CREATE INDEX IF NOT EXISTS idx_webhook_sub_type ON webhook_subscription (tenant_id, event_type, active); + +COMMENT ON TABLE webhook_subscription IS 'F099 아웃바운드 웹훅 구독 — 발송은 스텁(disabled). event_type별 콜백 등록'; +COMMENT ON COLUMN webhook_subscription.secret_hash IS 'SHA-256(secret) hex — 원문 미저장. 발송 disabled 이므로 재계산 불요'; + +-- ── 아웃바운드 발송 큐(스텁 — 실 HTTP 콜 없음, disabled 레코드만) ─────────────── +CREATE TABLE IF NOT EXISTS webhook_delivery ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + subscription_id varchar(40) NOT NULL, -- webhook_subscription.id 소프트 참조 + event_type varchar(60) NOT NULL, + payload_json text NOT NULL DEFAULT '{}', -- 발송 예정 페이로드(민감정보 제외 계약) + status varchar(20) NOT NULL DEFAULT 'disabled',-- disabled(스텁) — 실발송 시 queued|sent|failed + note varchar(300), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_webhook_delivery_sub ON webhook_delivery (tenant_id, subscription_id, created_at); + +COMMENT ON TABLE webhook_delivery IS 'F099 아웃바운드 발송 큐(스텁) — status=disabled 는 기록만·실 HTTP 콜 미수행'; + +-- ── ③ 인바운드 수신 엔드포인트 설정(토큰 + 봉인 시크릿) ──────────────────────── +-- POST /api/webhooks/in/{token} 가 조회할 설정. token 은 공개 식별자, secret_enc 는 HMAC 검증용(봉인 저장). +CREATE TABLE IF NOT EXISTS webhook_inbound_endpoint ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + token varchar(80) NOT NULL, -- 공개 경로 토큰(추측 불가·랜덤) + secret_enc varchar(400) NOT NULL, -- AES-256-GCM 봉인(enc:v1:...) — 응답 미노출 + source_label varchar(120), -- 발신 시스템 표시명(민감정보 아님) + active boolean NOT NULL DEFAULT true, + created_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + UNIQUE (token), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_webhook_in_token ON webhook_inbound_endpoint (token, active); + +COMMENT ON TABLE webhook_inbound_endpoint IS 'F099 인바운드 수신 엔드포인트 — token(공개) + secret_enc(봉인) 이중 검증'; +COMMENT ON COLUMN webhook_inbound_endpoint.secret_enc IS 'AES-256-GCM 봉인 HMAC 시크릿 — 응답 미노출. 검증에 원문 필요하여 가역 저장'; + +-- ── 인바운드 수신 이벤트 로그(서명 검증 결과 포함) ─────────────────────────── +CREATE TABLE IF NOT EXISTS webhook_inbound_event ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + endpoint_token varchar(80) NOT NULL, + event_type varchar(60), -- 페이로드 declared 유형(신뢰 전 검증됨) + payload_json text NOT NULL DEFAULT '{}', + signature_valid boolean NOT NULL DEFAULT false, + received_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_webhook_in_event ON webhook_inbound_event (tenant_id, endpoint_token, received_at); + +COMMENT ON TABLE webhook_inbound_event IS 'F099 인바운드 수신 이벤트 로그 — 토큰·HMAC 검증 후 저장(source IP 등 민감정보 미저장)'; + +-- ── ② F052 리드 팔로업 EDM 자동 트리거 멱등 로그 ───────────────────────────── +-- hot 리드(score>=80) 1건당 팔로업 캠페인 초안(edm_campaign, status=draft) 1건 자동 생성. +-- UNIQUE(event_id, lead_id) 가 스케줄 스윕 중복 생성을 차단하는 멱등 키. +CREATE TABLE IF NOT EXISTS edm_followup_log ( + tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', + id varchar(40) NOT NULL, + event_id varchar(40) NOT NULL, -- lead.event_id (soft ref) + lead_id varchar(40) NOT NULL, -- lead.id (soft ref) + campaign_id varchar(40), -- 생성된 edm_campaign.id + hot_score integer NOT NULL DEFAULT 0, + body_draft text, -- lead.followup_draft 스냅샷(추적용) + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id), + UNIQUE (event_id, lead_id), -- ★멱등 키: 리드당 팔로업 1회 + PRIMARY KEY (tenant_id, id) +); +CREATE INDEX IF NOT EXISTS idx_edm_followup_event ON edm_followup_log (tenant_id, event_id, created_at); + +COMMENT ON TABLE edm_followup_log IS 'F052 리드 팔로업 EDM 자동 트리거 멱등 로그 — hot 리드당 캠페인 초안 1회(발송 없음)'; + +-- ── 시드: 인바운드 데모 엔드포인트(봉인 시크릿은 애플리케이션이 최초 기동 시 채움) ── +-- 봉인은 SecretCipher(런타임 키) 필요 → SQL 로 평문 저장 금지. 데모 토큰만 예약하고 secret 은 +-- 부팅 시 WebhookInboundSeeder 가 미봉인('') 엔드포인트에 한해 채운다. 여기선 레코드 미삽입(안전). diff --git a/src/backend/src/main/resources/mybatis/mapper/NlQueryReadMapper.xml b/src/backend/src/main/resources/mybatis/mapper/NlQueryReadMapper.xml new file mode 100644 index 0000000..8bbd7b4 --- /dev/null +++ b/src/backend/src/main/resources/mybatis/mapper/NlQueryReadMapper.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/src/backend/src/main/resources/rulesets/refund-v1.0.json b/src/backend/src/main/resources/rulesets/refund-v1.0.json new file mode 100644 index 0000000..ea2cb71 --- /dev/null +++ b/src/backend/src/main/resources/rulesets/refund-v1.0.json @@ -0,0 +1,23 @@ +{ + "rulesetVersion": "refund-v1.0", + "effectiveDate": "2026-07-12", + "source": "PLANNING FEATURE_GAP F085 환불·취소 규정 / 킨텍스 참가계약 표준약관(취소 위약 구간)", + "disclaimer": "본 환불율은 취소 시점 기준 사전 산정용이며, 실제 환불(전자결제/PG 역거래)은 킨텍스 재무 확정 후 집행된다(G2 재무 협의 게이트). 본 시스템은 환불액 산정·기록·문서화까지만 수행한다.", + "currency": "KRW", + "policy": { + "basis": "eventDMinus", + "note": "취소 신청일 기준 행사 개막일(start_date)까지 남은 일수(D-일) 구간별 환불율. 기준액 = 실 납입액(paid_amount).", + "unknownDateRefundRatePercent": 0, + "unknownDateNote": "행사 개막일 미확정 시 자동 산정 불가 — 0%로 표기하고 재무 수기 검토 대상.", + "afterEventRefundRatePercent": 0, + "brackets": [ + { "minDaysBefore": 90, "refundRatePercent": 100, "label": "D-90 이전" }, + { "minDaysBefore": 60, "refundRatePercent": 90, "label": "D-60 ~ D-89" }, + { "minDaysBefore": 30, "refundRatePercent": 70, "label": "D-30 ~ D-59" }, + { "minDaysBefore": 14, "refundRatePercent": 50, "label": "D-14 ~ D-29" }, + { "minDaysBefore": 7, "refundRatePercent": 30, "label": "D-7 ~ D-13" }, + { "minDaysBefore": 0, "refundRatePercent": 0, "label": "D-6 이내" } + ] + }, + "rules": [] +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuardTest.java b/src/backend/src/test/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuardTest.java new file mode 100644 index 0000000..be8b527 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuardTest.java @@ -0,0 +1,141 @@ +package com.zioinfo.kintex.ai.nlquery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.zioinfo.kintex.ai.nlquery.NlQuerySqlGuard.GuardedSql; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.junit.jupiter.api.Test; + +/** + * F092 Text-to-SQL 안전 가드 회귀 — 금지어·비SELECT·화이트리스트·PII·다중문·LIMIT 강제. + * 이 가드가 LLM 산출 SQL 실행의 유일한 결정론 방어선이므로 위반 경로를 빠짐없이 검증한다. + */ +class NlQuerySqlGuardTest { + + private final NlQuerySqlGuard guard = new NlQuerySqlGuard(); + + private ApiException reject(String sql) { + return assertThrows(ApiException.class, () -> guard.validate(sql)); + } + + @Test + void validSelect_passes_andLimitAppended() { + GuardedSql g = guard.validate("SELECT id, name, status FROM event WHERE status = 'active'"); + assertTrue(g.sql().toLowerCase().contains("limit 200"), "LIMIT 200 강제 부여"); + assertEquals(200, g.limit()); + assertTrue(g.tables().contains("event")); + } + + @Test + void codeFence_stripped_andPasses() { + GuardedSql g = guard.validate("```sql\nSELECT id FROM hall\n```"); + assertTrue(g.sql().toLowerCase().startsWith("select")); + assertTrue(g.tables().contains("hall")); + } + + @Test + void existingLimitUnderCap_kept() { + GuardedSql g = guard.validate("SELECT id FROM booth LIMIT 50"); + assertEquals(50, g.limit()); + } + + @Test + void limitOverCap_rejected() { + assertEquals(ErrorCode.VALIDATION, reject("SELECT id FROM booth LIMIT 5000").getCode()); + } + + // ── 금지어(쓰기/DDL/제어) ── + @Test + void insert_rejected() { + assertEquals(ErrorCode.VALIDATION, reject("INSERT INTO event(id) VALUES ('x')").getCode()); + } + + @Test + void update_rejected() { + reject("UPDATE event SET status = 'x'"); + } + + @Test + void delete_rejected() { + reject("DELETE FROM event"); + } + + @Test + void dropAndAlter_rejected() { + reject("DROP TABLE event"); + reject("ALTER TABLE event ADD COLUMN x int"); + } + + @Test + void selectWithForbiddenKeyword_rejected() { + // SELECT 로 시작해도 본문에 쓰기/우회 키워드가 있으면 차단. + reject("SELECT id FROM event WHERE id IN (SELECT id FROM event) ; DROP TABLE hall"); + reject("SELECT id INTO temp FROM event"); + reject("SELECT pg_sleep(10)"); + } + + // ── 비 SELECT / 다중문 / 주석 ── + @Test + void nonSelect_rejected() { + reject("WITH x AS (SELECT 1) SELECT * FROM x"); // WITH 시작 금지(단일 SELECT 시작만) + reject("TRUNCATE event"); + } + + @Test + void multiStatement_rejected() { + assertEquals(ErrorCode.VALIDATION, + reject("SELECT id FROM event; SELECT id FROM hall").getCode()); + } + + @Test + void comment_rejected() { + reject("SELECT id FROM event -- comment"); + reject("SELECT id FROM event /* block */"); + } + + @Test + void trailingSemicolon_allowedAndStripped() { + GuardedSql g = guard.validate("SELECT id FROM event;"); + assertTrue(!g.sql().contains(";")); + } + + // ── 화이트리스트 테이블 ── + @Test + void nonWhitelistTable_rejected() { + assertEquals(ErrorCode.VALIDATION, reject("SELECT * FROM app_user").getCode()); + reject("SELECT * FROM company"); + reject("SELECT u.id FROM event e JOIN app_user u ON u.id = e.id"); + } + + @Test + void systemCatalog_rejected() { + reject("SELECT * FROM information_schema.tables"); + reject("SELECT * FROM pg_catalog.pg_tables"); + } + + // ── PII 컬럼 ── + @Test + void piiPhoneEmail_alwaysRejected() { + assertEquals(ErrorCode.VALIDATION, reject("SELECT phone FROM visitor_registration").getCode()); + reject("SELECT email FROM lead"); + } + + @Test + void nameOnPiiTable_rejected_butEventNameAllowed() { + // visitor_registration/lead 의 name 은 PII → 차단. + reject("SELECT name FROM visitor_registration"); + reject("SELECT l.name FROM lead l"); + // event.name 은 비-PII → 허용. + GuardedSql g = guard.validate("SELECT name FROM event"); + assertTrue(g.tables().contains("event")); + } + + @Test + void emptyOrBlank_rejected() { + reject(""); + reject(" "); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auction/BoqCalcTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auction/BoqCalcTest.java new file mode 100644 index 0000000..46d2041 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/auction/BoqCalcTest.java @@ -0,0 +1,68 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqLine; +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.assertTrue; + +/** + * F035 BOQ 규칙 기반 물량 산출 단위테스트 — 면적·둘레·부스수·유틸리티 주문 기반 라인 산식 고정. + */ +class BoqCalcTest { + + @Test + void computesRuleBasedLines_forSingleBooth() { + // 12×9=108㎡, 둘레 42m, 높이 3.5m, 유틸리티 1건. + List lines = BoqCalc.compute(108.0, 42.0, 1, 3.5, 1); + assertEquals(6, lines.size()); // 구조2 + 바닥1 + 조명1 + 전기1 + 설비1(유틸) + + assertEquals("구조", lines.get(0).trade()); + assertEquals(108L, lines.get(0).qty()); // 골조 = 바닥면적 + assertEquals(Math.round(42.0 * 3.5), lines.get(1).qty()); // 벽체 = 둘레×높이 = 147 + assertEquals(108L, lines.get(2).qty()); // 바닥 = 바닥면적 + assertEquals(12L, lines.get(3).qty()); // 조명 = ceil(108/9) = 12 + assertEquals(1L, lines.get(4).qty()); // 전기 = 부스 1실 + assertEquals("설비", lines.get(5).trade()); + assertEquals(1L, lines.get(5).qty()); // 설비 = 유틸리티 1건 + } + + @Test + void lightingRoundsUp() { + // 100㎡ → ceil(100/9)=12 + List lines = BoqCalc.compute(100.0, 40.0, 1, 3.5, 0); + assertEquals(12L, lines.get(3).qty()); + } + + @Test + void omitsUtilityLineWhenNoOrders() { + List lines = BoqCalc.compute(108.0, 42.0, 1, 3.5, 0); + assertEquals(5, lines.size()); + assertTrue(lines.stream().noneMatch(l -> "설비".equals(l.trade()))); + } + + @Test + void estimatesPerimeterWhenMissing() { + // 둘레 0 → 정사각형 근사 4√100 = 40, 벽체 = round(40×3.5) = 140 + List lines = BoqCalc.compute(100.0, 0.0, 2, 3.5, 0); + assertEquals(140L, lines.get(1).qty()); + assertTrue(lines.get(1).basis().contains("근사")); + assertEquals(2L, lines.get(4).qty()); // 전기 = 부스 2실 + } + + @Test + void usesDefaultHeightWhenMissing() { + // 높이 0 → 3.5 기본. 벽체 = round(40×3.5)=140 + List lines = BoqCalc.compute(100.0, 40.0, 1, 0.0, 0); + assertEquals(Math.round(40.0 * BoqCalc.DEFAULT_HEIGHT_M), lines.get(1).qty()); + } + + @Test + void emptyWhenNoBoothsOrZeroArea() { + assertTrue(BoqCalc.compute(0.0, 0.0, 0, 0.0, 0).isEmpty()); + assertTrue(BoqCalc.compute(100.0, 40.0, 0, 3.5, 0).isEmpty()); // boothCount 0 + assertTrue(BoqCalc.compute(0.0, 40.0, 1, 3.5, 0).isEmpty()); // area 0 + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auth/JwtServiceTenantTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auth/JwtServiceTenantTest.java new file mode 100644 index 0000000..d747758 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/auth/JwtServiceTenantTest.java @@ -0,0 +1,75 @@ +package com.zioinfo.kintex.auth; + +import com.zioinfo.kintex.tenant.TenantContext; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 테넌트 표준화(2026-07-12) 하위호환 단위테스트 — 표준 코드 대문자('KINTEX') 정규화가 + * 레거시 소문자 tid('kintex')·미지정 토큰을 계속 통과시키는지 검증한다. + */ +class JwtServiceTenantTest { + + private static final String SECRET = "kintex-test-secret-key-0123456789-abcdef"; // >= 32 bytes + + private JwtService newService() { + AuthProperties props = new AuthProperties(); + props.setJwtSecret(SECRET); + props.setAccessTokenTtlSeconds(3600); + return new JwtService(props); + } + + /** tid 미지정 발급 → 검증 시 기준 테넌트 'KINTEX'로 복원. */ + @Test + void issuesDefaultTenantAsUppercase() { + JwtService jwt = newService(); + String token = jwt.issue("u1", "사용자", Map.of(), false); + KintexPrincipal p = jwt.verify(token); + assertEquals("KINTEX", p.tenantId()); + } + + /** 소문자 'kintex'로 발급해도 검증 결과는 표준 대문자 'KINTEX'. */ + @Test + void normalizesLowercaseTenantOnIssue() { + JwtService jwt = newService(); + String token = jwt.issue("u1", "사용자", Map.of(), false, "MANAGER", "kintex"); + KintexPrincipal p = jwt.verify(token); + assertEquals("KINTEX", p.tenantId()); + } + + /** 레거시(기존 발급) 소문자 tid='kintex' 토큰도 검증 시 'KINTEX'로 정규화 복원(하위호환). */ + @Test + void verifiesLegacyLowercaseTidToken() { + JwtService jwt = newService(); + SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); + String legacy = Jwts.builder() + .subject("u-legacy") + .claim("name", "레거시") + .claim("roles", Map.of()) + .claim("hm", false) + .claim("tid", "kintex") // 표준화 이전 발급 토큰 모사 + .signWith(key) + .compact(); + KintexPrincipal p = jwt.verify(legacy); + assertEquals("KINTEX", p.tenantId()); + } + + /** TenantContext.normalize 계약 — trim+대문자, null/공백 → null. */ + @Test + void normalizeContract() { + assertEquals("KINTEX", TenantContext.normalize("kintex")); + assertEquals("KINTEX", TenantContext.normalize(" KINTEX ")); + assertEquals("COEX", TenantContext.normalize("coex")); + assertNull(TenantContext.normalize(null)); + assertNull(TenantContext.normalize(" ")); + assertEquals("KINTEX", TenantContext.currentTenantId()); // 미설정 폴백 + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/boothsales/BoothSalesServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/boothsales/BoothSalesServiceTest.java new file mode 100644 index 0000000..4687a83 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/boothsales/BoothSalesServiceTest.java @@ -0,0 +1,160 @@ +package com.zioinfo.kintex.boothsales; + +import com.zioinfo.kintex.auth.EventRole; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.boothsales.dto.BoothSalesDtos.*; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.settlement.SettlementService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * F005/F006 부스 판매 서비스 단위테스트 — 참가업체 인벤토리 필터(available+본인 hold)·서버 권위 가격 + * (override vs 면적×요율)·이중 hold/만료 구매 409·매니저 전용(block/price/settle) 403 검증. + */ +class BoothSalesServiceTest { + + private static final String EVENT = "e-2026-smf"; + private static final long RATE = 145_000L; // 원/㎡ + + private BoothSalesMapper mapper; + private SettlementService settlement; + private BoothSalesService service; + + private KintexPrincipal exhibitor; + private KintexPrincipal manager; + + @BeforeEach + void setUp() { + mapper = mock(BoothSalesMapper.class); + settlement = mock(SettlementService.class); + service = new BoothSalesService(mapper, settlement); + when(mapper.findRatePerM2()).thenReturn((double) RATE); + + exhibitor = new KintexPrincipal("u-ex", "참가업체A", Map.of(EVENT, EventRole.EXHIBITOR), false); + manager = new KintexPrincipal("u-mgr", "홀매니저", Map.of(), true); + } + + private Map row(String boothId, String status, Long unitPrice, String heldBy, double area) { + Map m = new HashMap<>(); + m.put("boothId", boothId); + m.put("boothNo", boothId); + m.put("boothType", "independent"); + m.put("hallId", "H1"); + m.put("hallLabel", "제1전시장 홀1"); + m.put("areaM2", area); + m.put("status", status); + m.put("unitPrice", unitPrice); + m.put("heldBy", heldBy); + m.put("heldUntil", null); + m.put("soldToCompany", null); + return m; + } + + @Test + void exhibitorSeesOnlyAvailableAndOwnHold_withServerPrice() { + List> rows = List.of( + row("b1", "available", null, null, 100.0), // 보임 — 파생가 100×145000 + row("b2", "held", null, "u-ex", 50.0), // 보임 — 본인 hold + row("b3", "held", null, "u-other", 60.0), // 숨김 — 타인 hold + row("b4", "sold", null, "u-other", 70.0), // 숨김 — 판매됨 + row("b5", "available", 9_000_000L, null, 40.0) // 보임 — override 가격 + ); + when(mapper.findInventory(anyString(), eq(EVENT), any())).thenReturn(rows); + + InventoryResponse inv = service.inventory(EVENT, null, exhibitor); + + // 요약은 전 상태 반영(필터 전 카운트: available b1,b5 / held b2,b3 / sold b4), 목록은 참가업체 가시분만. + assertEquals(2, inv.available()); + assertEquals(2, inv.held()); + assertEquals(1, inv.sold()); + assertFalse(inv.canManage()); + assertEquals(3, inv.units().size()); // available b1,b5 + 본인 hold b2 + + BoothSaleUnit b1 = inv.units().stream().filter(u -> u.boothId().equals("b1")).findFirst().orElseThrow(); + assertEquals(100.0 * RATE, b1.unitPrice()); // 서버 권위 파생가 + assertFalse(b1.heldByMe()); + + BoothSaleUnit b2 = inv.units().stream().filter(u -> u.boothId().equals("b2")).findFirst().orElseThrow(); + assertTrue(b2.heldByMe()); + + BoothSaleUnit b5 = inv.units().stream().filter(u -> u.boothId().equals("b5")).findFirst().orElseThrow(); + assertEquals(9_000_000L, b5.unitPrice()); // override 우선 + } + + @Test + void managerSeesAllStatuses() { + List> rows = List.of( + row("b3", "held", null, "u-other", 60.0), + row("b4", "sold", null, "u-other", 70.0), + row("b6", "blocked", null, null, 30.0) + ); + when(mapper.findInventory(anyString(), eq(EVENT), any())).thenReturn(rows); + + InventoryResponse inv = service.inventory(EVENT, null, manager); + assertTrue(inv.canManage()); + assertEquals(3, inv.units().size()); // 전 상태 노출 + } + + @Test + void holdConflictWhenAtomicTransitionAffectsZeroRows() { + Map booth = new HashMap<>(); + booth.put("boothId", "b1"); + booth.put("boothNo", "b1"); + booth.put("hallId", "H1"); + booth.put("areaM2", 100.0); + when(mapper.findBooth(EVENT, "b1")).thenReturn(booth); + when(mapper.holdBooth(any())).thenReturn(0); // 이중 hold — 원자 전이 실패 + + ApiException ex = assertThrows(ApiException.class, + () -> service.hold(new HoldRequest(EVENT, "b1"), exhibitor)); + assertEquals(ErrorCode.CONFLICT, ex.getCode()); + } + + @Test + void purchaseConflictWhenHoldExpiredOrNotHolder() { + Map booth = new HashMap<>(); + booth.put("boothId", "b1"); + booth.put("boothNo", "b1"); + booth.put("hallId", "H1"); + booth.put("areaM2", 100.0); + booth.put("assignedCompany", "참가업체A"); + when(mapper.findBooth(EVENT, "b1")).thenReturn(booth); + when(mapper.purchaseBooth(any())).thenReturn(0); + + ApiException ex = assertThrows(ApiException.class, + () -> service.purchase(new PurchaseRequest(EVENT, "b1", null), exhibitor)); + assertEquals(ErrorCode.CONFLICT, ex.getCode()); + } + + @Test + void managementRequiresManager() { + // 참가업체는 block/price/settle 불가 → FORBIDDEN (mapper 호출 이전 차단). + ApiException block = assertThrows(ApiException.class, + () -> service.setBlocked(new BlockRequest(EVENT, "b1", true, "통로 확보"), exhibitor)); + assertEquals(ErrorCode.FORBIDDEN, block.getCode()); + + ApiException price = assertThrows(ApiException.class, + () -> service.setPrice(new PriceRequest(EVENT, "b1", 5_000_000L), exhibitor)); + assertEquals(ErrorCode.FORBIDDEN, price.getCode()); + + ApiException settle = assertThrows(ApiException.class, + () -> service.settle(EVENT, exhibitor)); + assertEquals(ErrorCode.FORBIDDEN, settle.getCode()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java index 4639128..1bef028 100644 --- a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java +++ b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java @@ -1,5 +1,7 @@ package com.zioinfo.kintex.cms; +import com.zioinfo.kintex.ai.AiTextRouter; +import com.zioinfo.kintex.ai.AiTextRouter.AiResult; import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.cms.dto.CmsContentUpdateRequest; import com.zioinfo.kintex.common.error.ApiException; @@ -14,16 +16,18 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; /** - * M17 CMS 서비스 단위테스트 — 게시 전이 가드(전진만·권한)·본문 sanitize·버전 스냅샷·롤백·AI 번역 501. + * M17 CMS 서비스 단위테스트 — 게시 전이 가드(전진만·권한)·본문 sanitize·버전 스냅샷·롤백·AI 번역(F100 배선). */ class CmsServiceTest { private CmsMapper mapper; private SystemAccessGuard scope; + private AiTextRouter aiRouter; private CmsService service; private final KintexPrincipal editor = @@ -33,7 +37,8 @@ class CmsServiceTest { void setUp() { mapper = mock(CmsMapper.class); scope = mock(SystemAccessGuard.class); - service = new CmsService(mapper, scope, "./build/test-uploads"); + aiRouter = mock(AiTextRouter.class); + service = new CmsService(mapper, scope, aiRouter, "./build/test-uploads"); } private Map contentRow(String status) { @@ -141,12 +146,49 @@ class CmsServiceTest { assertEquals(ErrorCode.NOT_FOUND, ex.getCode()); } - // ── AI 번역 스텁 501 ─────────────────────────────────────────────────────── + // ── AI 번역 (F100 배선) ────────────────────────────────────────────────────── + /** AI 미가용(degraded) 시 환각 없이 503(AI_UNAVAILABLE) — 저장하지 않는다. */ @Test - void aiTranslateNotImplementedReturns501Code() { + void aiTranslateDegradedReturns503AndDoesNotPersist() { when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); - ApiException ex = assertThrows(ApiException.class, () -> service.aiTranslate("cms-1", "en")); - assertEquals(ErrorCode.NOT_IMPLEMENTED, ex.getCode()); + when(aiRouter.generate(anyString(), anyInt())).thenReturn(new AiResult(null, "none", true)); + ApiException ex = assertThrows(ApiException.class, + () -> service.aiTranslate(editor, "cms-1", "en")); + assertEquals(ErrorCode.AI_UNAVAILABLE, ex.getCode()); + verify(scope).requireManager(editor); // 매니저↑ 게이트 + verify(mapper, never()).upsertTranslation(any()); // 실패 시 저장 안 함 + } + + /** ko(원문 언어)로의 번역 요청은 거부(400). */ + @Test + void aiTranslateRejectsSourceLang() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + ApiException ex = assertThrows(ApiException.class, + () -> service.aiTranslate(editor, "cms-1", "ko")); + assertEquals(ErrorCode.VALIDATION, ex.getCode()); + } + + /** AI 성공 시 sanitize 후 trans_status='ai' 로 upsert. */ + @Test + void aiTranslatePersistsAiDraftOnSuccess() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + when(aiRouter.generate(anyString(), anyInt())) + .thenReturn(new AiResult("{\"title\":\"Title\",\"body\":\"

Body

\"}", "claude", false)); + Map trRow = new HashMap<>(); + trRow.put("contentId", "cms-1"); + trRow.put("lang", "en"); + trRow.put("title", "Title"); + trRow.put("body", "

Body

"); + trRow.put("transStatus", "ai"); + when(mapper.findTranslations("cms-1")).thenReturn(java.util.List.of(trRow)); + service.aiTranslate(editor, "cms-1", "en"); + ArgumentCaptor> cap = ArgumentCaptor.forClass(Map.class); + verify(mapper).upsertTranslation(cap.capture()); + assertEquals("en", cap.getValue().get("lang")); + assertEquals("ai", cap.getValue().get("transStatus")); + String body = String.valueOf(cap.getValue().get("body")); + assertTrue(body.contains("

Body

"), "허용 태그 보존"); + assertFalse(body.toLowerCase().contains(" BRACKETS = List.of( + new RefundCalc.Bracket(90, 100, "D-90 이전"), + new RefundCalc.Bracket(60, 90, "D-60 ~ D-89"), + new RefundCalc.Bracket(30, 70, "D-30 ~ D-59"), + new RefundCalc.Bracket(14, 50, "D-14 ~ D-29"), + new RefundCalc.Bracket(7, 30, "D-7 ~ D-13"), + new RefundCalc.Bracket(0, 0, "D-6 이내")); + + @Test + void selectsBracketByDaysBefore() { + assertEquals(100, RefundCalc.bracketFor(120, BRACKETS).refundRatePercent()); + assertEquals(100, RefundCalc.bracketFor(90, BRACKETS).refundRatePercent()); // 경계 포함 + assertEquals(90, RefundCalc.bracketFor(89, BRACKETS).refundRatePercent()); + assertEquals(70, RefundCalc.bracketFor(30, BRACKETS).refundRatePercent()); // 데모: e-2026-smf D-30 + assertEquals(50, RefundCalc.bracketFor(20, BRACKETS).refundRatePercent()); + assertEquals(30, RefundCalc.bracketFor(7, BRACKETS).refundRatePercent()); + assertEquals(0, RefundCalc.bracketFor(3, BRACKETS).refundRatePercent()); + assertEquals(0, RefundCalc.bracketFor(0, BRACKETS).refundRatePercent()); + } + + @Test + void afterEventHasNoMatchingPositiveBracketButLandsAtZero() { + // 음수(행사 후)는 minDaysBefore=0 구간도 만족하지 못하므로 미매칭(호출부에서 afterEvent 0% 처리) + assertNull(RefundCalc.bracketFor(-1, BRACKETS)); + } + + @Test + void refundAmountRoundsHalfUp() { + // base 3,960,000 × 70% = 2,772,000 + assertEquals(2_772_000L, RefundCalc.refundAmount(3_960_000L, 70)); + // 반올림(half-up): 3 × 50% = 1.5 → 2 + assertEquals(2L, RefundCalc.refundAmount(3L, 50)); + // 1,000,001 × 33.33% = 333,300.33 → 333,300 + assertEquals(333_300L, RefundCalc.refundAmount(1_000_001L, 33.33)); + } + + @Test + void refundZeroWhenNoBaseOrZeroRate() { + assertEquals(0L, RefundCalc.refundAmount(0L, 100)); + assertEquals(0L, RefundCalc.refundAmount(1_000_000L, 0)); + } + + @Test + void rateClampedTo100() { + assertEquals(1_000_000L, RefundCalc.refundAmount(1_000_000L, 150)); + } + + @Test + void forfeitIsBaseMinusRefundNonNegative() { + long base = 3_960_000L; + long refund = RefundCalc.refundAmount(base, 70); + assertEquals(base - refund, RefundCalc.forfeitAmount(base, refund)); + assertEquals(0L, RefundCalc.forfeitAmount(100L, 200L)); // 환불>기준(방어) → 0 + } + + @Test + void emptyBracketsReturnNull() { + assertNull(RefundCalc.bracketFor(50, List.of())); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java index 871f7ac..428f022 100644 --- a/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java +++ b/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java @@ -1,5 +1,6 @@ package com.zioinfo.kintex.visitor; +import com.zioinfo.kintex.ai.AiTextRouter; import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinRequest; import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinResult; import com.zioinfo.kintex.visitor.scoring.RuleBasedLeadScorer; @@ -36,7 +37,7 @@ class VisitorCheckinServiceTest { @BeforeEach void setUp() { mapper = mock(VisitorMapper.class); - service = new VisitorService(mapper, new RuleBasedLeadScorer()); + service = new VisitorService(mapper, new RuleBasedLeadScorer(), mock(AiTextRouter.class)); } private static Map row(String state, String checkinAt) { diff --git a/src/backend/src/test/java/com/zioinfo/kintex/webhook/LeadFollowupAutomationTest.java b/src/backend/src/test/java/com/zioinfo/kintex/webhook/LeadFollowupAutomationTest.java new file mode 100644 index 0000000..0563754 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/webhook/LeadFollowupAutomationTest.java @@ -0,0 +1,74 @@ +package com.zioinfo.kintex.webhook; + +import com.zioinfo.kintex.webhook.automation.FollowupMapper; +import com.zioinfo.kintex.webhook.automation.LeadFollowupAutomation; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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; + +/** + * F052 팔로업 EDM 자동 트리거 멱등성 단위테스트(FollowupMapper 모킹). + */ +class LeadFollowupAutomationTest { + + private Map lead(String id, String eventId, int score) { + return Map.of("leadId", id, "eventId", eventId, "score", score, + "company", "(주)테크솔루션", "product", "협동로봇", "bodyDraft", "draft body"); + } + + @Test + void createsOneCampaignPerReservedHotLead() { + FollowupMapper mapper = mock(FollowupMapper.class); + when(mapper.findUnprocessedHotLeads(anyInt())) + .thenReturn(List.of(lead("ld-1", "e-1", 87), lead("ld-2", "e-1", 82))); + // 둘 다 예약 성공(신규). + when(mapper.reserveFollowup(anyString(), anyString(), anyString(), anyInt(), any())) + .thenReturn(1); + + LeadFollowupAutomation auto = new LeadFollowupAutomation(mapper); + int created = auto.sweepOnce(); + + assertEquals(2, created, "hot 리드 2건 → 캠페인 초안 2건"); + verify(mapper, times(2)).insertFollowupCampaign(anyString(), anyString(), anyString(), anyString()); + verify(mapper, times(2)).linkCampaign(anyString(), anyString()); + } + + @Test + void idempotentSkipsAlreadyProcessedLead() { + FollowupMapper mapper = mock(FollowupMapper.class); + when(mapper.findUnprocessedHotLeads(anyInt())) + .thenReturn(List.of(lead("ld-1", "e-1", 90))); + // 이미 처리됨 — 로그 예약 충돌(0) → 캠페인 생성 금지. + when(mapper.reserveFollowup(anyString(), eq("e-1"), eq("ld-1"), anyInt(), any())) + .thenReturn(0); + + LeadFollowupAutomation auto = new LeadFollowupAutomation(mapper); + int created = auto.sweepOnce(); + + assertEquals(0, created, "예약 충돌 시 캠페인 미생성(멱등)"); + verify(mapper, never()).insertFollowupCampaign(anyString(), anyString(), anyString(), anyString()); + verify(mapper, never()).linkCampaign(anyString(), anyString()); + } + + @Test + void noCandidatesProducesNoCampaign() { + FollowupMapper mapper = mock(FollowupMapper.class); + when(mapper.findUnprocessedHotLeads(anyInt())).thenReturn(List.of()); + + LeadFollowupAutomation auto = new LeadFollowupAutomation(mapper); + assertEquals(0, auto.sweepOnce()); + verify(mapper, never()).reserveFollowup(anyString(), anyString(), anyString(), anyInt(), any()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/webhook/WebhookInboundServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/webhook/WebhookInboundServiceTest.java new file mode 100644 index 0000000..0429680 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/webhook/WebhookInboundServiceTest.java @@ -0,0 +1,76 @@ +package com.zioinfo.kintex.webhook; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.security.SecretCipher; +import com.zioinfo.kintex.security.SecurityPolicyProperties; +import com.zioinfo.kintex.webhook.dto.WebhookDtos.InboundReceipt; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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; + +/** + * F099 인바운드 수신 서비스 — 토큰 + HMAC 이중 검증 흐름 단위테스트. + */ +class WebhookInboundServiceTest { + + private final WebhookSignature signature = new WebhookSignature(); + private final SecretCipher cipher = new SecretCipher(new SecurityPolicyProperties()); + + private WebhookService service(WebhookMapper mapper) { + return new WebhookService(mapper, signature, cipher); + } + + @Test + void acceptsValidSignatureAndStores() { + WebhookMapper mapper = mock(WebhookMapper.class); + String secret = "whsec_inbound_test"; + // 저장된 봉인 시크릿 반환. + when(mapper.findInboundSecretEnc("tok-1")) + .thenReturn(Map.of("secretEnc", cipher.seal(secret))); + + String body = "{\"type\":\"invoice.paid\"}"; + String validSig = signature.sign(secret, body); + + InboundReceipt receipt = service(mapper).receiveInbound("tok-1", body, validSig, "invoice.paid"); + + assertTrue(receipt.accepted()); + verify(mapper, times(1)).insertInboundEvent(anyString(), eq("tok-1"), + eq("invoice.paid"), eq(body), eq(true)); + } + + @Test + void rejectsInvalidSignatureWithoutStoring() { + WebhookMapper mapper = mock(WebhookMapper.class); + when(mapper.findInboundSecretEnc("tok-1")) + .thenReturn(Map.of("secretEnc", cipher.seal("real-secret"))); + + ApiException ex = assertThrows(ApiException.class, + () -> service(mapper).receiveInbound("tok-1", "{}", "deadbeef", "x")); + assertEquals(ErrorCode.UNAUTHORIZED, ex.getCode()); + // 무효 서명은 적재하지 않는다(DoS/스팸 완화). + verify(mapper, never()).insertInboundEvent(anyString(), anyString(), anyString(), anyString(), anyBoolean()); + } + + @Test + void rejectsUnknownToken() { + WebhookMapper mapper = mock(WebhookMapper.class); + when(mapper.findInboundSecretEnc(anyString())).thenReturn(null); + + ApiException ex = assertThrows(ApiException.class, + () -> service(mapper).receiveInbound("nope", "{}", "sig", "x")); + assertEquals(ErrorCode.UNAUTHORIZED, ex.getCode()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/webhook/WebhookSignatureTest.java b/src/backend/src/test/java/com/zioinfo/kintex/webhook/WebhookSignatureTest.java new file mode 100644 index 0000000..ba02de2 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/webhook/WebhookSignatureTest.java @@ -0,0 +1,57 @@ +package com.zioinfo.kintex.webhook; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * F099 인바운드 웹훅 HMAC-SHA256 서명 서명/검증 단위테스트. + */ +class WebhookSignatureTest { + + private final WebhookSignature sig = new WebhookSignature(); + + @Test + void signThenVerifyRoundTrips() { + String secret = "whsec_kintex_test_1234567890"; + String body = "{\"type\":\"lead.hot\",\"leadId\":\"ld-1\"}"; + String signed = sig.sign(secret, body); + assertTrue(sig.verify(secret, body, signed), "정상 서명 검증 통과"); + assertTrue(sig.verify(secret, body, "sha256=" + signed), "sha256= 접두어 허용"); + assertTrue(sig.verify(secret, body, signed.toUpperCase()), "대문자 hex 허용"); + } + + @Test + void rejectsTamperedBody() { + String secret = "whsec_kintex_test_1234567890"; + String signed = sig.sign(secret, "{\"amount\":100}"); + assertFalse(sig.verify(secret, "{\"amount\":999}", signed), "본문 변조 서명 거부"); + } + + @Test + void rejectsWrongSecret() { + String body = "{\"type\":\"invoice.paid\"}"; + String signed = sig.sign("real-secret", body); + assertFalse(sig.verify("attacker-secret", body, signed), "다른 시크릿 거부"); + } + + @Test + void rejectsBlankOrNull() { + assertFalse(sig.verify(null, "b", "s")); + assertFalse(sig.verify("s", "b", null)); + assertFalse(sig.verify("s", "b", "")); + assertFalse(sig.verify("", "b", "s")); + } + + @Test + void hashSecretIsDeterministicAndNotPlaintext() { + String h1 = sig.hashSecret("my-secret"); + String h2 = sig.hashSecret("my-secret"); + assertEquals(h1, h2, "동일 입력 동일 해시"); + assertEquals(64, h1.length(), "SHA-256 hex 64자"); + assertNotEquals("my-secret", h1, "저장값은 평문과 다름"); + } +} diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 2603eaa..20b3d98 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -12,6 +12,8 @@ import { UtilityOrderSummaryPage } from './screens/utility/UtilityOrderSummaryPa import { ComplianceReportPage } from './screens/compliance/ComplianceReportPage'; import { ManagerApprovalQueuePage } from './screens/approval/ManagerApprovalQueuePage'; import { ReviewDetailsPage } from './screens/approval/ReviewDetailsPage'; +import { HomePage } from './screens/home/HomePage'; +import { BoothSalesPage } from './screens/boothsales/BoothSalesPage'; import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage'; import { AnalyticsDashboardPage } from './screens/analytics/AnalyticsDashboardPage'; import { HallOperationsPage } from './screens/ops/HallOperationsPage'; @@ -150,6 +152,12 @@ export function App() { } /> {/* SCR-14 홀 현장운영 대시보드 (M14) */} } /> + {/* SCR-HOME 메인 홈(로그인 후 랜딩) — design.md v2.1.2 확정판 */} + } /> + {/* F005/F006 부스 판매(셀프 선택) */} + } /> + {/* §5B 전자결재 결재함(F028) — 홈 내 할 일 route 정합(/work/approval) */} + } /> {/* SCR-15 전시 일정 관리 (M1·M6) */} } /> {/* M9 정산 대시보드 · M1 홀 배정+자동견적 */} diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index c044323..6a0184f 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore'; import { useThemeStore } from '../../store/themeStore'; import { useMdiStore } from '../../store/mdiStore'; import { MdiTabBar } from './MdiTabBar'; +import { ShellFooter } from './ShellFooter'; import { labelForPath } from './mdiLabels'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { DdayChip } from '../ui/Badge'; @@ -18,6 +19,7 @@ import { IconDocument, IconExhibitors, IconFloorplan, + IconHome, IconMoon, IconOperations, IconSearch, @@ -42,6 +44,8 @@ const NAV_ITEMS: { to?: string; scoped?: boolean; }[] = [ + // 메인 홈(SCR-HOME) — 로그인 후 랜딩. 라우트 /home 배선은 통합자(App.tsx) 소관. + { key: 'home', label: '홈', Icon: IconHome, to: '/home' }, { key: 'dashboard', label: '대시보드', Icon: IconDashboard, scoped: true }, { key: 'floorplan', label: '플로어플랜 스튜디오', Icon: IconFloorplan, scoped: true }, { key: 'exhibitors', label: '참가업체 관리', Icon: IconExhibitors, scoped: true }, @@ -263,6 +267,9 @@ export function AppShell() {
+ + {/* 전 셸 화면 공통 하단 상태바 (design.md §2-2, 약 36px) */} + ); diff --git a/src/frontend/src/components/layout/ShellFooter.tsx b/src/frontend/src/components/layout/ShellFooter.tsx new file mode 100644 index 0000000..5687b8a --- /dev/null +++ b/src/frontend/src/components/layout/ShellFooter.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** 빌드 버전 — 디자인 §2-2 예시(v2.1.2). 릴리스 시 갱신. */ +const APP_VERSION = 'v2.1.2'; + +/** + * 웹 인증 셸 하단 상태바/푸터 (design.md §2-2, v2.1.2 신설, 약 36px). + * 사이드바 우측 전폭·DocumentHost 아래 고정. 좌측 저작권, 우측 시스템 상태점·버전·고객지원. + * 시스템 상태는 정적 "정상"이 기본이며, 마운트 시 /health 1회만 확인해 명시적 5xx 일 때만 강등한다 + * (폴링 없음 — 과도한 헬스체크 회피). 색 외 텍스트 병기(WCAG AA), role="contentinfo". + */ +export function ShellFooter() { + const { t } = useTranslation(); + const [healthy, setHealthy] = useState(true); + + useEffect(() => { + const ctrl = new AbortController(); + fetch('/health', { signal: ctrl.signal }) + .then((res) => { + // 명시적 서버 오류(5xx)만 강등 — 404/네트워크 등은 정적 정상 유지(오탐 방지). + if (res.status >= 500) setHealthy(false); + }) + .catch(() => { + /* 네트워크/중단 — 정적 정상 유지 */ + }); + return () => ctrl.abort(); + }, []); + + return ( +
+ {t('shell.footerbar.copyright')} +
+ + + + {APP_VERSION} + + + {t('shell.footerbar.support')} + +
+
+ ); +} diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index cac39b8..69de0ca 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -251,6 +251,62 @@ display: flex; } +/* ── 하단 상태바/푸터 (design.md §2-2, 약 36px) — 사이드바 우측 전폭·콘텐츠 아래 고정 ── */ +.kx-shell__footer { + flex-shrink: 0; + height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--gutter); + background: var(--color-neutral-050); + border-top: var(--border-card); + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-shell__footer-copy { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.kx-shell__footer-right { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} +.kx-shell__footer-status { + display: inline-flex; + align-items: center; + gap: 5px; +} +.kx-shell__footer-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-neutral-500); +} +.kx-shell__footer-status.is-ok .kx-shell__footer-dot { + background: var(--color-success); +} +.kx-shell__footer-status.is-down { + color: var(--color-error); +} +.kx-shell__footer-status.is-down .kx-shell__footer-dot { + background: var(--color-error); +} +.kx-shell__footer-sep { + color: var(--color-neutral-200); +} +.kx-shell__footer-link { + color: var(--color-neutral-500); + text-decoration: none; +} +.kx-shell__footer-link:hover { + color: var(--color-primary-600); + text-decoration: underline; +} + /* ── MDI 문서 탭 스트립 (design.md §2.7-2) ── */ .kx-mdi { flex-shrink: 0; diff --git a/src/frontend/src/components/ui/icons.tsx b/src/frontend/src/components/ui/icons.tsx index b45ed56..dbe9062 100644 --- a/src/frontend/src/components/ui/icons.tsx +++ b/src/frontend/src/components/ui/icons.tsx @@ -59,6 +59,17 @@ export function IconDashboard(p: IconProps) { ); } +/** ⌂ 홈(메인 랜딩 SCR-HOME). */ +export function IconHome(p: IconProps) { + return ( + + + + + + ); +} + /** ▤ 캘린더/일정 (Stitch icon_calendar). */ export function IconCalendar(p: IconProps) { return ( diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index ced444f..336f1de 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -62,6 +62,12 @@ "contactTel": "Main line 1899-1001", "contactEmail": "exhibition@kintex.com", "bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + }, + "footerbar": { + "copyright": "© 2026 KINTEX. All rights reserved.", + "systemOk": "System OK", + "systemDown": "Maintenance", + "support": "Support" } }, "home": { @@ -101,7 +107,105 @@ "mapAria": "KINTEX location map", "mapAddress": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si", "mapPlace": "KINTEX Exhibition Center 1 & 2", - "mapViewAria": "View map" + "mapViewAria": "View map", + "pageAria": "Main home", + "greeting": "Hello, {{name}}", + "greetingGuest": "Hello", + "viewAll": "View all", + "loadError": "Failed to load", + "role": { + "organizer": "Organizer", + "exhibitor": "Exhibitor", + "contractor": "Contractor", + "hallManager": "Hall Manager", + "admin": "Admin" + }, + "kpi": { + "aria": "Summary metrics" + }, + "banner": { + "aria": "Event promo banner", + "prev": "Previous banner", + "next": "Next banner", + "emptyTitle": "No banners registered", + "emptyDesc": "Registered event banners appear here." + }, + "showcase": { + "title": "Ongoing & Upcoming Exhibitions", + "all": "View all", + "prev": "Previous", + "next": "Next", + "statusOngoing": "Ongoing", + "statusUpcoming": "Upcoming", + "emptyTitle": "No exhibitions in this period", + "emptyCta": "View full schedule", + "tab": { + "ongoing": "Ongoing", + "thisMonth": "This month", + "nextMonth": "Next month" + } + }, + "calendar": { + "title": "Exhibition Schedule", + "prev": "Previous month", + "next": "Next month", + "today": "Today", + "all": "All", + "centerFilter": "Exhibition center filter", + "yearMonth": "{{y}}.{{m}}", + "legendOngoing": "Ongoing", + "legendUpcoming": "Upcoming", + "legendEnded": "Ended", + "more": "+{{n}}", + "moreTitle": "{{date}} exhibitions", + "popupEmpty": "No exhibitions on this day", + "listTitle": "Ongoing & Upcoming", + "listEmpty": "No upcoming exhibitions", + "listEmptyCta": "View full schedule", + "loadError": "Failed to load schedule" + }, + "todo": { + "title": "My Tasks", + "noDue": "No due date", + "emptyTitle": "All done", + "emptyDesc": "No tasks to handle", + "emptyCta": "Open notifications" + }, + "notice": { + "title": "Notices", + "more": "More", + "new": "NEW", + "pinned": "Pinned", + "emptyTitle": "No notices", + "emptyCta": "View all notices" + }, + "quick": { + "title": "Quick Actions", + "editor": "Layout Editor", + "invite": "Invite Exhibitor", + "docs": "Documents", + "settlement": "Settlement", + "notice": "Write Notice", + "search": "Search" + }, + "activity": { + "title": "Recent Activity", + "emptyTitle": "No recent activity", + "emptyCta": "Notification settings" + }, + "time": { + "justNow": "just now", + "minutes": "{{n}} min ago", + "hours": "{{n}} hr ago", + "days": "{{n}} days ago" + }, + "workspace": { + "title": "My Events", + "emptyTitle": "No events joined", + "emptyDesc": "Check your invite link or code", + "invite": "Verify code", + "codePh": "Enter invite code" + } }, "detail": { "tabs": { @@ -626,7 +730,63 @@ "day": "Day {{d}}", "dayHasEvent": "Day {{d}} (has event)", "hallUtil": "Hall utilization", - "hallUtilMonth": "Hall utilization ({{m}}/{{y}})" + "hallUtilMonth": "Hall utilization ({{m}}/{{y}})", + "availTitle": "Hall availability", + "availMonth": "Hall availability ({{m}}/{{y}})", + "availHalls": "{{n}} halls total", + "availFreeDays": "{{free}}/{{total}} days free", + "availOpen": "Available", + "availFull": "Full" + }, + "boothsales": { + "title": "Booth Sales", + "subtitleManager": "Manage booth sales inventory — status, price, blocking and settlement", + "subtitleExhibitor": "Pick an available booth, hold it, then confirm purchase", + "noEventTitle": "Please select an event", + "noEventDesc": "No event selected for booth sales.", + "summaryAria": "Booth sales status summary", + "stAvailable": "Available", + "stHeld": "Held", + "stSold": "Sold", + "stBlocked": "Blocked", + "rate": "Rental rate {{rate}} KRW/㎡ ({{ver}})", + "hallFilter": "Hall filter", + "allHalls": "All halls", + "loadError": "Failed to load booth sales inventory.", + "emptyTitle": "No booths to show", + "emptyManage": "No booths registered in this event's layout.", + "emptyExhibitor": "No available booths to select right now.", + "status": { + "available": "Available", + "held": "Held", + "sold": "Sold", + "blocked": "Blocked" + }, + "wonUnit": " KRW", + "hallLabel": "Hall", + "areaLabel": "Area", + "soldTo": "Sold to: {{c}}", + "hold": "Hold", + "purchase": "Confirm purchase", + "release": "Release hold", + "setPrice": "Set price", + "block": "Block", + "unblock": "Unblock", + "settle": "Generate invoices", + "settling": "Generating…", + "heldToast": "Booth held. Confirm your purchase before it expires.", + "purchasedToast": "Purchase confirmed.", + "releasedToast": "Hold released.", + "blockToast": "Booth status updated.", + "priceToast": "Sale price updated.", + "settleToast": "Invoices generated — {{created}} new, {{skipped}} kept.", + "settleFail": "Failed to generate invoices (check permissions).", + "holdConflict": "This booth is already held by another user or sold/blocked.", + "purchaseConflict": "Your hold expired or the booth was taken by another user.", + "blockConflict": "A sold booth cannot be blocked.", + "actionFail": "Failed to process the request.", + "pricePrompt": "Enter sale price (KRW). Leave blank to restore area × rate.", + "priceInvalid": "Enter an amount of 0 or more." }, "settlement": { "title": "Settlement & Payment", @@ -657,7 +817,8 @@ "pending": "Unissued", "invoiced": "Invoiced", "paid": "Paid", - "overdue": "Overdue" + "overdue": "Overdue", + "cancelled": "Cancelled" }, "listError": "Failed to load the invoice list.", "emptyTitle": "No invoices", @@ -706,7 +867,47 @@ "errPositive": "Please enter an amount greater than 0.", "errExceed": "Cannot exceed the outstanding balance.", "pgNote": "Actual payment is delegated to an external PG. This screen is for recording payment confirmation only.", - "managerOnly": "Only organizers/hall managers can record or issue payments." + "managerOnly": "Only organizers/hall managers can record or issue payments.", + "colCategory": "Type", + "catRental": "Rental/Utility", + "catAuctionFee": "Auction fee", + "reportTitle": "Settlement Report", + "reportShow": "Show report", + "reportHide": "Close", + "reportHint": "Aggregates totals, collection rate and outstanding by category/status, with CSV export.", + "reportError": "Failed to load the report.", + "reportCsv": "Download CSV", + "reportByCategory": "By category", + "reportByStatus": "By status", + "reportEmpty": "No invoices to aggregate.", + "reportNote": "Totals exclude cancelled invoices. Amounts are server-computed.", + "reportRate": "Collection %", + "reportCount": "Count", + "taxTitle": "Tax Invoice", + "taxNo": "Invoice No.", + "taxSupplier": "Supplier", + "taxBuyer": "Buyer", + "taxItem": "Item", + "taxNone": "No tax invoice issued yet.", + "taxIssue": "Issue tax invoice", + "taxIssuing": "Issuing…", + "taxIssueFail": "Failed to issue the tax invoice.", + "cancelledNote": "This invoice has been cancelled.", + "cancelInvoice": "Cancel invoice", + "refundPreview": "Refund preview", + "refundError": "Failed to load the refund estimate.", + "refundBracket": "Bracket", + "refundRate": "Refund %", + "refundBase": "Base (paid)", + "refundAmount": "Refund", + "forfeitAmount": "Forfeit", + "refundManual": "Auto estimate unavailable (e.g. event date not set) — flagged for finance review.", + "cancelReason": "Cancellation reason", + "cancelReasonPh": "Reason (optional)", + "cancelAbort": "Abort", + "cancelConfirm": "Confirm cancel", + "cancelling": "Processing…", + "cancelFail": "Failed to cancel the invoice." }, "hallassign": { "title": "Hall Assignment · Auto Quote", @@ -760,6 +961,48 @@ "deposit": "Management deposit (separate)", "ruleset": "(ruleset {{v}})" }, + "ai": { + "console": { + "title": "AI Console", + "subtitle": "Query data in natural language and ask about exhibition regulations. Results are for reference and shown with their basis.", + "badge": "Reference", + "tabsAria": "AI console tabs", + "tabNlquery": "Natural-language query", + "tabRules": "Regulation chatbot" + }, + "common": { + "running": "Running…", + "provClaude": "Claude response", + "provOllama": "Ollama response", + "provRules": "From regulation text", + "provNone": "No response", + "unavailable": "The AI feature is temporarily unavailable. Please try again shortly.", + "invalid": "This query violates safety rules and cannot be executed.", + "forbidden": "You are not authorized to use this feature.", + "error": "An error occurred while processing the request." + }, + "nlquery": { + "placeholder": "e.g., Show events with status active", + "inputAria": "Natural-language query question", + "run": "Query", + "guardNote": "Only read-only SELECT runs; personal-data columns are never queried. Up to 200 rows.", + "rowCount": "{{n}} rows", + "showSql": "Show generated SQL", + "hideSql": "Hide generated SQL", + "sqlAria": "Generated SQL", + "emptyTitle": "No results", + "emptyDesc": "No data matched the condition. Try rephrasing your question." + }, + "rules": { + "placeholder": "e.g., What is the maximum structure height?", + "inputAria": "Regulation question", + "run": "Ask", + "groundNote": "Answers are grounded in the regulation and rate rulesets; if no basis is found we tell you honestly.", + "citations": "Cited regulations", + "notFound": "Not found in regulations", + "degraded": "Regulation summary (AI unavailable)" + } + }, "analytics": { "title": "Business Analytics", "subtitle": "KINTEX operating profitability · exhibitor insights", @@ -1278,5 +1521,164 @@ "updateFail": "Update failed.", "deleteFail": "Deletion failed.", "deleteConfirm": "Delete the slide \"{{title}}\"?" + }, + "approval": { + "queueTitle": "Approval Inbox", + "search": "Search", + "searchPlaceholder": "Search title...", + "pendingCount": "Pending", + "pendingList": "Documents awaiting my approval", + "colTitle": "Title", + "colDocType": "Type", + "colDrafter": "Drafter", + "colSubmitted": "Submitted", + "colStep": "Step", + "colStatus": "Status", + "colAction": "Action", + "open": "Open", + "emptyPending": "No documents to approve", + "status": { + "DRAFT": "Draft", + "SUBMITTED": "Submitted", + "IN_PROGRESS": "In progress", + "APPROVED": "Approved", + "REJECTED": "Rejected", + "CANCELED": "Canceled" + }, + "reviewTitle": "Approval Review", + "version": "Version", + "drafter": "Drafter", + "step": "Step", + "demoMode": "Preview (demo)", + "downloadOriginal": "Download original", + "previewAria": "Document preview", + "lineAria": "Approval line & history", + "docBody": "Body", + "line": "Approval line", + "history": "History", + "noHistory": "No history yet", + "lineStatus": { + "WAIT": "Waiting", + "CURRENT": "In review", + "APPROVED": "Approved", + "REJECTED": "Rejected", + "SKIPPED": "Skipped" + }, + "action": { + "SUBMIT": "Submit", + "APPROVE": "Approve", + "REJECT": "Reject", + "RESUBMIT": "Resubmit", + "CANCEL": "Cancel" + }, + "approve": "Approve", + "reject": "Reject", + "approvedDone": "Approved.", + "rejectedDone": "Rejected.", + "canActHint": "You are the current approver for this document.", + "notApproverHint": "Only the current-step approver can act.", + "demoFootNote": "Select a document from the approval inbox to approve or reject.", + "demoHint": "No approval document selected. Please open one from the approval inbox.", + "commentPlaceholder": "Enter a comment (required to reject)", + "comment": "Comment" + }, + "auction": { + "boq": { + "title": "Bill of Quantities (auto)", + "error": "Failed to load the bill of quantities.", + "scopeBooth": "Booth {{label}}", + "scopeEvent": "All booths in the event", + "summary": "{{count}} booth(s) · {{area}}㎡", + "colSeq": "No", + "colTrade": "Trade", + "colItem": "Item", + "colQty": "Qty", + "colUnit": "Unit", + "colBasis": "Basis" + } + }, + "logistics": { + "title": "Logistics Hub", + "subtitle": "Move-in/out docks · equipment · rentals · inbound tracking", + "tabsAria": "Switch logistics function", + "tabs": { + "dock": "Dock Booking", + "equipment": "Equipment", + "rental": "Rentals", + "inbound": "Inbound" + }, + "boothNo": "Booth No.", + "qty": "Qty", + "estimate": "Estimated amount", + "submitting": "Processing…", + "submitOk": "Request received.", + "submitErr": "Request failed.", + "statusUpdated": "Status updated.", + "loadErr": "Failed to load logistics data.", + "serverAmountNote": "Amount is finalized by the server", + "equipment": { + "formTitle": "Equipment Request", + "item": "Equipment", + "days": "Days of use", + "daysUnit": "d", + "useDate": "Planned use date", + "submit": "Request Equipment", + "listTitle": "Requests", + "empty": "No requests yet", + "emptyDesc": "Request forklifts, lifts and more.", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Rejected due to site conditions", + "done": "Mark done", + "st": { + "requested": "Requested", + "approved": "Approved", + "rejected": "Rejected", + "done": "Done" + } + }, + "rental": { + "formTitle": "Rental Order", + "item": "Rental item", + "start": "Start date", + "end": "End date", + "submit": "Place Order", + "listTitle": "Orders", + "empty": "No orders yet", + "emptyDesc": "Order tables, chairs, monitors and more.", + "period": "{{days}} day(s)", + "confirm": "Confirm", + "cancel": "Cancel", + "deliver": "Mark delivered", + "st": { + "ordered": "Ordered", + "confirmed": "Confirmed", + "cancelled": "Cancelled", + "delivered": "Delivered" + } + }, + "inbound": { + "formTitle": "Register Inbound Shipment", + "trackingNo": "Tracking no.", + "carrier": "Carrier", + "itemDesc": "Cargo", + "eta": "ETA date", + "submit": "Register Shipment", + "submitSub": "Track inbound status after registering", + "listTitle": "Inbound Status", + "empty": "No inbound shipments", + "emptyDesc": "Register pre-delivery tracking numbers.", + "st": { + "registered": "Registered", + "inbound_scheduled": "Scheduled", + "arrived_warehouse": "At warehouse", + "delivered_booth": "Delivered to booth" + }, + "advance": { + "inbound_scheduled": "Mark scheduled", + "arrived_warehouse": "Mark arrived", + "delivered_booth": "Mark delivered to booth" + } + } } } diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 7fc2571..bbbfdb7 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -62,6 +62,12 @@ "contactTel": "代表番号 1899-1001", "contactEmail": "exhibition@kintex.com", "bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + }, + "footerbar": { + "copyright": "© 2026 KINTEX. All rights reserved.", + "systemOk": "システム正常", + "systemDown": "システム点検", + "support": "カスタマーサポート" } }, "home": { @@ -101,7 +107,105 @@ "mapAria": "KINTEX 位置地図", "mapAddress": "高陽市一山西区KINTEXロ217-60", "mapPlace": "KINTEX 第1・2展示場", - "mapViewAria": "地図を見る" + "mapViewAria": "地図を見る", + "pageAria": "メインホーム", + "greeting": "こんにちは、{{name}}様", + "greetingGuest": "こんにちは", + "viewAll": "すべて表示", + "loadError": "読み込めませんでした", + "role": { + "organizer": "主催者", + "exhibitor": "出展社", + "contractor": "施工業者", + "hallManager": "ホールマネージャー", + "admin": "管理者" + }, + "kpi": { + "aria": "サマリー指標" + }, + "banner": { + "aria": "イベント紹介バナー", + "prev": "前のバナー", + "next": "次のバナー", + "emptyTitle": "登録されたバナーがありません", + "emptyDesc": "管理者で登録したイベントバナーがここに表示されます。" + }, + "showcase": { + "title": "開催中・開催予定の展示会", + "all": "すべて表示", + "prev": "前へ", + "next": "次へ", + "statusOngoing": "開催中", + "statusUpcoming": "予定", + "emptyTitle": "この期間の展示会はありません", + "emptyCta": "全日程を見る", + "tab": { + "ongoing": "開催中", + "thisMonth": "今月", + "nextMonth": "来月" + } + }, + "calendar": { + "title": "展示会スケジュール", + "prev": "前月", + "next": "翌月", + "today": "今日", + "all": "すべて", + "centerFilter": "展示場フィルター", + "yearMonth": "{{y}}年{{m}}月", + "legendOngoing": "開催中", + "legendUpcoming": "予定", + "legendEnded": "終了", + "more": "+{{n}}", + "moreTitle": "{{date}} の展示会", + "popupEmpty": "その日の展示会はありません", + "listTitle": "開催中・開催予定", + "listEmpty": "予定された展示会はありません", + "listEmptyCta": "全日程を見る", + "loadError": "スケジュールを読み込めませんでした" + }, + "todo": { + "title": "やること", + "noDue": "期限なし", + "emptyTitle": "すべて完了", + "emptyDesc": "対応する作業はありません", + "emptyCta": "通知を開く" + }, + "notice": { + "title": "お知らせ", + "more": "もっと見る", + "new": "NEW", + "pinned": "固定", + "emptyTitle": "お知らせはありません", + "emptyCta": "すべてのお知らせ" + }, + "quick": { + "title": "クイック操作", + "editor": "レイアウトエディタ", + "invite": "出展社を招待", + "docs": "書類ワークフロー", + "settlement": "精算", + "notice": "お知らせ作成", + "search": "統合検索" + }, + "activity": { + "title": "最近の活動", + "emptyTitle": "最近の活動はありません", + "emptyCta": "通知設定" + }, + "time": { + "justNow": "たった今", + "minutes": "{{n}}分前", + "hours": "{{n}}時間前", + "days": "{{n}}日前" + }, + "workspace": { + "title": "マイイベント", + "emptyTitle": "参加中のイベントがありません", + "emptyDesc": "招待リンクまたは招待コードをご確認ください", + "invite": "招待コード確認", + "codePh": "招待コードを入力" + } }, "detail": { "tabs": { @@ -626,7 +730,63 @@ "day": "{{d}}日", "dayHasEvent": "{{d}}日 (イベントあり)", "hallUtil": "ホール稼働率", - "hallUtilMonth": "ホール稼働率 ({{y}}年{{m}}月)" + "hallUtilMonth": "ホール稼働率 ({{y}}年{{m}}月)", + "availTitle": "ホール空き状況", + "availMonth": "ホール空き状況 ({{y}}年{{m}}月)", + "availHalls": "全ホール {{n}} 件", + "availFreeDays": "空き {{free}}/{{total}}日", + "availOpen": "空きあり", + "availFull": "満室" + }, + "boothsales": { + "title": "ブース販売", + "subtitleManager": "ブース販売在庫の管理 — 状態・価格・ブロックおよび精算連携", + "subtitleExhibitor": "空きブースを選んで仮押さえ後、購入を確定してください", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "ブース販売の対象イベントが未選択です。", + "summaryAria": "ブース販売状態のサマリー", + "stAvailable": "空き", + "stHeld": "仮押さえ", + "stSold": "販売済", + "stBlocked": "ブロック", + "rate": "賃料レート {{rate}}ウォン/㎡ ({{ver}})", + "hallFilter": "ホール絞り込み", + "allHalls": "全ホール", + "loadError": "ブース販売在庫を読み込めませんでした。", + "emptyTitle": "表示するブースがありません", + "emptyManage": "このイベントのレイアウトに登録されたブースがありません。", + "emptyExhibitor": "現在選択可能な空きブースがありません。", + "status": { + "available": "空き", + "held": "仮押さえ", + "sold": "販売済", + "blocked": "ブロック" + }, + "wonUnit": "ウォン", + "hallLabel": "ホール", + "areaLabel": "面積", + "soldTo": "販売先:{{c}}", + "hold": "仮押さえ", + "purchase": "購入確定", + "release": "仮押さえ解除", + "setPrice": "価格設定", + "block": "ブロック", + "unblock": "ブロック解除", + "settle": "請求書生成", + "settling": "生成中…", + "heldToast": "ブースを仮押さえしました。期限内に購入を確定してください。", + "purchasedToast": "購入が確定しました。", + "releasedToast": "仮押さえを解除しました。", + "blockToast": "ブース状態を更新しました。", + "priceToast": "販売価格を更新しました。", + "settleToast": "請求書生成完了 — 新規 {{created}}件、維持 {{skipped}}件。", + "settleFail": "請求書の生成に失敗しました(権限を確認してください)。", + "holdConflict": "このブースは他のユーザーが仮押さえ済み、または販売済/ブロック済です。", + "purchaseConflict": "仮押さえが期限切れ、または他のユーザーが仮押さえしました。", + "blockConflict": "販売済のブースはブロックできません。", + "actionFail": "リクエストを処理できませんでした。", + "pricePrompt": "販売価格(ウォン)を入力してください。空欄の場合は面積×レートで自動計算します。", + "priceInvalid": "0以上の金額を入力してください。" }, "settlement": { "title": "精算・決済", @@ -657,7 +817,8 @@ "pending": "未発行", "invoiced": "請求済", "paid": "完納", - "overdue": "延滞" + "overdue": "延滞", + "cancelled": "取消" }, "listError": "請求書一覧を読み込めませんでした。", "emptyTitle": "請求書がありません", @@ -706,7 +867,47 @@ "errPositive": "0より大きい金額を入力してください。", "errExceed": "未収残高を超えることはできません。", "pgNote": "実際の決済は外部 PG に委任されます。本画面は入金確認記録専用です。", - "managerOnly": "入金・発行は主催者/ホールマネージャーのみ実行できます。" + "managerOnly": "入金・発行は主催者/ホールマネージャーのみ実行できます。", + "colCategory": "種別", + "catRental": "賃貸·設備", + "catAuctionFee": "オークション手数料", + "reportTitle": "精算レポート", + "reportShow": "レポート表示", + "reportHide": "閉じる", + "reportHint": "カテゴリ・状態別の合計、収納率、未収を集計しCSVで出力します。", + "reportError": "レポートを読み込めませんでした。", + "reportCsv": "CSVダウンロード", + "reportByCategory": "カテゴリ別合計", + "reportByStatus": "状態別合計", + "reportEmpty": "集計対象の請求がありません。", + "reportNote": "合計は取消請求を除いた値です。金額はサーバー計算基準です。", + "reportRate": "収納率", + "reportCount": "件数", + "taxTitle": "税金計算書", + "taxNo": "計算書番号", + "taxSupplier": "供給者", + "taxBuyer": "供給を受ける者", + "taxItem": "品目", + "taxNone": "発行された税金計算書がありません。", + "taxIssue": "税金計算書発行", + "taxIssuing": "発行中…", + "taxIssueFail": "税金計算書の発行に失敗しました。", + "cancelledNote": "取消された請求です。", + "cancelInvoice": "請求取消", + "refundPreview": "返金試算プレビュー", + "refundError": "返金試算を読み込めませんでした。", + "refundBracket": "適用区間", + "refundRate": "返金率", + "refundBase": "基準額(入金)", + "refundAmount": "返金額", + "forfeitAmount": "控除・違約額", + "refundManual": "開催日未確定などで自動試算ができません — 財務の手動確認対象です。", + "cancelReason": "取消理由", + "cancelReasonPh": "取消理由(任意)", + "cancelAbort": "中断", + "cancelConfirm": "取消確定", + "cancelling": "処理中…", + "cancelFail": "請求の取消に失敗しました。" }, "hallassign": { "title": "ホール割当 · 自動見積", @@ -760,6 +961,48 @@ "deposit": "管理費予託金(別途)", "ruleset": "(ルールセット {{v}})" }, + "ai": { + "console": { + "title": "AIコンソール", + "subtitle": "自然言語でデータを照会し、展示規定を質問します。結果は参考用で、根拠も併記します。", + "badge": "参考用", + "tabsAria": "AIコンソールタブ", + "tabNlquery": "自然言語照会", + "tabRules": "規定チャットボット" + }, + "common": { + "running": "実行中…", + "provClaude": "Claude応答", + "provOllama": "Ollama応答", + "provRules": "規定原文に基づく", + "provNone": "応答なし", + "unavailable": "AI機能が一時的に利用できません。しばらくして再度お試しください。", + "invalid": "安全ルールに違反するため実行できない照会です。", + "forbidden": "この機能を使用する権限がありません。", + "error": "リクエスト処理中にエラーが発生しました。" + }, + "nlquery": { + "placeholder": "例:ステータスがactiveのイベント一覧を表示", + "inputAria": "自然言語照会の質問", + "run": "照会", + "guardNote": "読み取り専用のSELECTのみ実行し、個人情報の列は照会しません。最大200行。", + "rowCount": "{{n}}行", + "showSql": "生成SQLを表示", + "hideSql": "生成SQLを非表示", + "sqlAria": "生成されたSQL", + "emptyTitle": "結果がありません", + "emptyDesc": "条件に該当するデータがありません。質問を変えて再度お試しください。" + }, + "rules": { + "placeholder": "例:装置物の最大高さはいくらですか?", + "inputAria": "規定に関する質問", + "run": "質問", + "groundNote": "規定・料率ルールセットを根拠に回答し、根拠がなければ正直にお知らせします。", + "citations": "根拠規定", + "notFound": "規定で見つかりませんでした", + "degraded": "規定原文の要約(AI利用不可)" + } + }, "analytics": { "title": "経営分析", "subtitle": "KINTEX 運営収益性 · 出展社インサイト", @@ -1278,5 +1521,164 @@ "updateFail": "変更に失敗しました。", "deleteFail": "削除に失敗しました。", "deleteConfirm": "\"{{title}}\" スライドを削除しますか?" + }, + "approval": { + "queueTitle": "承認受信箱", + "search": "検索", + "searchPlaceholder": "タイトル検索...", + "pendingCount": "承認待ち", + "pendingList": "自分の承認待ち文書", + "colTitle": "タイトル", + "colDocType": "種別", + "colDrafter": "起案者", + "colSubmitted": "申請日", + "colStep": "段階", + "colStatus": "状態", + "colAction": "処理", + "open": "開く", + "emptyPending": "承認する文書がありません", + "status": { + "DRAFT": "一時保存", + "SUBMITTED": "申請", + "IN_PROGRESS": "進行中", + "APPROVED": "承認完了", + "REJECTED": "却下", + "CANCELED": "取消" + }, + "reviewTitle": "承認レビュー", + "version": "バージョン", + "drafter": "起案者", + "step": "段階", + "demoMode": "プレビュー(デモ)", + "downloadOriginal": "原本ダウンロード", + "previewAria": "文書プレビュー", + "lineAria": "承認ライン・履歴", + "docBody": "本文", + "line": "承認ライン", + "history": "処理履歴", + "noHistory": "処理履歴がありません", + "lineStatus": { + "WAIT": "待機", + "CURRENT": "審査中", + "APPROVED": "承認", + "REJECTED": "却下", + "SKIPPED": "スキップ" + }, + "action": { + "SUBMIT": "申請", + "APPROVE": "承認", + "REJECT": "却下", + "RESUBMIT": "再申請", + "CANCEL": "取消" + }, + "approve": "承認", + "reject": "却下", + "approvedDone": "承認しました。", + "rejectedDone": "却下しました。", + "canActHint": "この文書の現在の承認者です。", + "notApproverHint": "現在段階の承認者のみ処理できます。", + "demoFootNote": "承認受信箱から文書を選択すると承認/却下できます。", + "demoHint": "承認文書が指定されていません。承認受信箱から開いてください。", + "commentPlaceholder": "コメント(却下時は必須)を入力", + "comment": "コメント" + }, + "auction": { + "boq": { + "title": "数量書(BOQ)自動算出", + "error": "数量書を読み込めませんでした。", + "scopeBooth": "{{label}} ブース基準", + "scopeEvent": "イベント全ブース集計", + "summary": "ブース {{count}}件 · {{area}}㎡", + "colSeq": "No", + "colTrade": "工種", + "colItem": "品名", + "colQty": "数量", + "colUnit": "単位", + "colBasis": "算出根拠" + } + }, + "logistics": { + "title": "物流ハブ", + "subtitle": "搬入・搬出ドック · 付帯機材 · レンタル · 事前入荷追跡", + "tabsAria": "物流機能の切替", + "tabs": { + "dock": "ドック予約", + "equipment": "機材申請", + "rental": "レンタル注文", + "inbound": "事前入荷" + }, + "boothNo": "ブース番号", + "qty": "数量", + "estimate": "概算金額", + "submitting": "処理中…", + "submitOk": "受け付けました。", + "submitErr": "処理に失敗しました。", + "statusUpdated": "ステータスを変更しました。", + "loadErr": "物流情報を読み込めませんでした。", + "serverAmountNote": "金額はサーバー基準で確定します", + "equipment": { + "formTitle": "付帯機材の申請", + "item": "機材品目", + "days": "使用日数", + "daysUnit": "日", + "useDate": "使用予定日", + "submit": "機材を申請", + "listTitle": "申請履歴", + "empty": "申請履歴がありません", + "emptyDesc": "フォークリフト・リフト等を申請してください。", + "approve": "承認", + "reject": "却下", + "rejectReason": "現場状況により却下", + "done": "処理完了", + "st": { + "requested": "申請済", + "approved": "承認", + "rejected": "却下", + "done": "完了" + } + }, + "rental": { + "formTitle": "付帯物品のレンタル注文", + "item": "レンタル品目", + "start": "開始日", + "end": "終了日", + "submit": "レンタル注文", + "listTitle": "注文履歴", + "empty": "注文履歴がありません", + "emptyDesc": "テーブル・椅子・モニター等を注文してください。", + "period": "{{days}}日", + "confirm": "確定", + "cancel": "キャンセル", + "deliver": "納品完了", + "st": { + "ordered": "注文済", + "confirmed": "確定", + "cancelled": "キャンセル", + "delivered": "納品" + } + }, + "inbound": { + "formTitle": "事前入荷の送り状登録", + "trackingNo": "送り状番号", + "carrier": "運送会社", + "itemDesc": "貨物内容", + "eta": "入荷予定日", + "submit": "送り状を登録", + "submitSub": "登録後に入荷状況を追跡します", + "listTitle": "事前入荷状況", + "empty": "事前入荷がありません", + "emptyDesc": "事前配送の送り状を登録してください。", + "st": { + "registered": "登録済", + "inbound_scheduled": "入荷予定", + "arrived_warehouse": "倉庫到着", + "delivered_booth": "ブース配達" + }, + "advance": { + "inbound_scheduled": "入荷予定にする", + "arrived_warehouse": "倉庫到着にする", + "delivered_booth": "ブース配達完了" + } + } } } diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index 51855cc..e315f3f 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -62,6 +62,12 @@ "contactTel": "대표번호 1899-1001", "contactEmail": "exhibition@kintex.com", "bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + }, + "footerbar": { + "copyright": "© 2026 KINTEX. All rights reserved.", + "systemOk": "시스템 정상", + "systemDown": "시스템 점검", + "support": "고객지원" } }, "home": { @@ -101,7 +107,105 @@ "mapAria": "킨텍스 위치 지도", "mapAddress": "고양시 일산서구 킨텍스로 217-60", "mapPlace": "KINTEX 제1, 2전시장", - "mapViewAria": "지도 보기" + "mapViewAria": "지도 보기", + "pageAria": "메인 홈", + "greeting": "안녕하세요, {{name}}님", + "greetingGuest": "안녕하세요", + "viewAll": "전체 보기", + "loadError": "불러오지 못했습니다", + "role": { + "organizer": "주최자", + "exhibitor": "참가업체", + "contractor": "장치업체", + "hallManager": "홀매니저", + "admin": "관리자" + }, + "kpi": { + "aria": "요약 지표" + }, + "banner": { + "aria": "행사 소개 배너", + "prev": "이전 배너", + "next": "다음 배너", + "emptyTitle": "등록된 배너가 없습니다", + "emptyDesc": "관리자에서 행사 배너를 등록하면 이곳에 노출됩니다." + }, + "showcase": { + "title": "진행 중·다가오는 전시", + "all": "전체 보기", + "prev": "이전", + "next": "다음", + "statusOngoing": "진행중", + "statusUpcoming": "예정", + "emptyTitle": "해당 기간 전시가 없습니다", + "emptyCta": "전체 일정 보기", + "tab": { + "ongoing": "진행 중", + "thisMonth": "이번 달", + "nextMonth": "다음 달" + } + }, + "calendar": { + "title": "전시 일정", + "prev": "이전 달", + "next": "다음 달", + "today": "오늘", + "all": "전체", + "centerFilter": "전시장 필터", + "yearMonth": "{{y}}년 {{m}}월", + "legendOngoing": "진행중", + "legendUpcoming": "예정", + "legendEnded": "종료", + "more": "+{{n}}", + "moreTitle": "{{date}} 전시", + "popupEmpty": "해당 일자에 전시가 없습니다", + "listTitle": "진행 중·예정 전시", + "listEmpty": "예정된 전시가 없습니다", + "listEmptyCta": "전체 일정 보기", + "loadError": "일정을 불러오지 못했습니다" + }, + "todo": { + "title": "내 할 일", + "noDue": "기한 없음", + "emptyTitle": "모두 완료", + "emptyDesc": "처리할 작업이 없습니다", + "emptyCta": "알림 열기" + }, + "notice": { + "title": "공지사항", + "more": "더보기", + "new": "NEW", + "pinned": "고정", + "emptyTitle": "등록된 공지가 없습니다", + "emptyCta": "전체 공지 보기" + }, + "quick": { + "title": "빠른 작업", + "editor": "배치 에디터", + "invite": "참가업체 초대", + "docs": "서류 워크플로", + "settlement": "정산", + "notice": "공지 작성", + "search": "통합검색" + }, + "activity": { + "title": "최근 활동", + "emptyTitle": "최근 활동이 없습니다", + "emptyCta": "알림 설정" + }, + "time": { + "justNow": "방금", + "minutes": "{{n}}분 전", + "hours": "{{n}}시간 전", + "days": "{{n}}일 전" + }, + "workspace": { + "title": "내 행사", + "emptyTitle": "참여 중인 행사가 없습니다", + "emptyDesc": "초대 링크 또는 초대 코드를 확인하세요", + "invite": "초대 코드 확인", + "codePh": "초대 코드 입력" + } }, "detail": { "tabs": { @@ -626,7 +730,63 @@ "day": "{{d}}일", "dayHasEvent": "{{d}}일 (행사 있음)", "hallUtil": "홀 가동률", - "hallUtilMonth": "홀 가동률 ({{y}}년 {{m}}월)" + "hallUtilMonth": "홀 가동률 ({{y}}년 {{m}}월)", + "availTitle": "홀 가용 현황", + "availMonth": "홀 가용 ({{y}}년 {{m}}월)", + "availHalls": "전체 홀 {{n}}개", + "availFreeDays": "가용 {{free}}/{{total}}일", + "availOpen": "가용", + "availFull": "만실" + }, + "boothsales": { + "title": "부스 판매", + "subtitleManager": "부스 판매 인벤토리 관리 — 상태·가격·차단 및 정산 연계", + "subtitleExhibitor": "가용 부스를 선택해 선점 후 구매를 확정하세요", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "부스 판매를 조회할 행사가 지정되지 않았습니다.", + "summaryAria": "부스 판매 상태 요약", + "stAvailable": "가용", + "stHeld": "선점", + "stSold": "판매완료", + "stBlocked": "차단", + "rate": "임대 요율 {{rate}}원/㎡ ({{ver}})", + "hallFilter": "홀 필터", + "allHalls": "전체 홀", + "loadError": "부스 판매 인벤토리를 불러오지 못했습니다.", + "emptyTitle": "표시할 부스가 없습니다", + "emptyManage": "이 행사의 배치안(layout)에 등록된 부스가 없습니다.", + "emptyExhibitor": "현재 선택 가능한 가용 부스가 없습니다.", + "status": { + "available": "가용", + "held": "선점됨", + "sold": "판매완료", + "blocked": "차단됨" + }, + "wonUnit": "원", + "hallLabel": "홀", + "areaLabel": "면적", + "soldTo": "판매처: {{c}}", + "hold": "선점하기", + "purchase": "구매 확정", + "release": "선점 해제", + "setPrice": "가격 설정", + "block": "차단", + "unblock": "차단 해제", + "settle": "청구서 생성", + "settling": "생성 중…", + "heldToast": "부스를 선점했습니다. 제한 시간 내에 구매를 확정하세요.", + "purchasedToast": "구매가 확정되었습니다.", + "releasedToast": "선점을 해제했습니다.", + "blockToast": "부스 상태를 변경했습니다.", + "priceToast": "판매가를 변경했습니다.", + "settleToast": "청구서 생성 완료 — 신규 {{created}}건, 기존 유지 {{skipped}}건.", + "settleFail": "청구서 생성에 실패했습니다(권한 확인).", + "holdConflict": "이미 다른 사용자가 선점했거나 판매/차단된 부스입니다.", + "purchaseConflict": "선점이 만료되었거나 다른 사용자가 선점한 부스입니다.", + "blockConflict": "이미 판매된 부스는 차단할 수 없습니다.", + "actionFail": "요청을 처리하지 못했습니다.", + "pricePrompt": "판매가(원)를 입력하세요. 빈칸이면 면적×요율 자동 계산으로 복원합니다.", + "priceInvalid": "0 이상의 금액을 입력해 주세요." }, "settlement": { "title": "정산·결제", @@ -657,7 +817,8 @@ "pending": "미발행", "invoiced": "청구됨", "paid": "완납", - "overdue": "연체" + "overdue": "연체", + "cancelled": "취소" }, "listError": "청구서 목록을 불러오지 못했습니다.", "emptyTitle": "청구서가 없습니다", @@ -706,7 +867,47 @@ "errPositive": "0보다 큰 금액을 입력해 주세요.", "errExceed": "미수 잔액을 초과할 수 없습니다.", "pgNote": "실제 결제는 외부 PG로 위임됩니다. 본 화면은 수납 확인 기록 전용입니다.", - "managerOnly": "수납·발행은 주최자/홀매니저만 수행할 수 있습니다." + "managerOnly": "수납·발행은 주최자/홀매니저만 수행할 수 있습니다.", + "colCategory": "유형", + "catRental": "임대·유틸", + "catAuctionFee": "옥션 수수료", + "reportTitle": "정산 리포트", + "reportShow": "리포트 보기", + "reportHide": "닫기", + "reportHint": "카테고리·상태별 합계와 수납율·미수를 집계하고 CSV로 내려받습니다.", + "reportError": "리포트를 불러오지 못했습니다.", + "reportCsv": "CSV 다운로드", + "reportByCategory": "카테고리별 합계", + "reportByStatus": "상태별 합계", + "reportEmpty": "집계할 청구가 없습니다.", + "reportNote": "합계는 취소된 청구를 제외한 값입니다. 금액은 서버 계산 기준입니다.", + "reportRate": "수납율", + "reportCount": "건수", + "taxTitle": "세금계산서", + "taxNo": "계산서 번호", + "taxSupplier": "공급자", + "taxBuyer": "공급받는자", + "taxItem": "품목", + "taxNone": "발행된 세금계산서가 없습니다.", + "taxIssue": "세금계산서 발행", + "taxIssuing": "발행 중…", + "taxIssueFail": "세금계산서 발행에 실패했습니다.", + "cancelledNote": "취소된 청구입니다.", + "cancelInvoice": "청구 취소", + "refundPreview": "환불 산정 미리보기", + "refundError": "환불 산정을 불러오지 못했습니다.", + "refundBracket": "적용 구간", + "refundRate": "환불율", + "refundBase": "기준액(납입)", + "refundAmount": "환불액", + "forfeitAmount": "공제·위약액", + "refundManual": "행사 개막일 미확정 등으로 자동 산정이 불가합니다 — 재무 수기 검토 대상입니다.", + "cancelReason": "취소 사유", + "cancelReasonPh": "취소 사유(선택)", + "cancelAbort": "취소 중단", + "cancelConfirm": "취소 확정", + "cancelling": "처리 중…", + "cancelFail": "청구 취소에 실패했습니다." }, "hallassign": { "title": "홀 배정 · 자동 견적", @@ -760,6 +961,48 @@ "deposit": "관리비 예치금(별도)", "ruleset": "(룰셋 {{v}})" }, + "ai": { + "console": { + "title": "AI 콘솔", + "subtitle": "자연어로 데이터를 조회하고 전시 규정을 질의합니다. 결과는 참고용이며 근거를 함께 표기합니다.", + "badge": "참고용", + "tabsAria": "AI 콘솔 탭", + "tabNlquery": "자연어 조회", + "tabRules": "규정 챗봇" + }, + "common": { + "running": "실행 중…", + "provClaude": "Claude 응답", + "provOllama": "Ollama 응답", + "provRules": "규정 원문 기반", + "provNone": "응답 없음", + "unavailable": "AI 기능을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", + "invalid": "안전 규칙에 위배되어 실행할 수 없는 조회입니다.", + "forbidden": "이 기능을 사용할 권한이 없습니다.", + "error": "요청 처리 중 오류가 발생했습니다." + }, + "nlquery": { + "placeholder": "예: 상태가 active 인 행사 목록을 보여줘", + "inputAria": "자연어 조회 질문", + "run": "조회", + "guardNote": "읽기 전용 SELECT만 실행되며 개인정보 컬럼은 조회되지 않습니다. 최대 200행.", + "rowCount": "{{n}}행", + "showSql": "생성 SQL 보기", + "hideSql": "생성 SQL 숨기기", + "sqlAria": "생성된 SQL", + "emptyTitle": "결과가 없습니다", + "emptyDesc": "조건에 해당하는 데이터가 없습니다. 질문을 바꿔 다시 시도해 주세요." + }, + "rules": { + "placeholder": "예: 장치물 최대 높이가 얼마인가요?", + "inputAria": "규정 질문", + "run": "질의", + "groundNote": "규정·요율 룰셋을 근거로 답하며, 근거가 없으면 정직하게 알려드립니다.", + "citations": "근거 규정", + "notFound": "규정에서 찾지 못함", + "degraded": "규정 원문 요약(AI 미가용)" + } + }, "analytics": { "title": "경영분석", "subtitle": "킨텍스 운영 수익성 · 참가업체 인사이트", @@ -1278,5 +1521,164 @@ "updateFail": "변경에 실패했습니다.", "deleteFail": "삭제에 실패했습니다.", "deleteConfirm": "\"{{title}}\" 슬라이드를 삭제할까요?" + }, + "approval": { + "queueTitle": "결재 대기함", + "search": "검색", + "searchPlaceholder": "제목 검색…", + "pendingCount": "결재 대기", + "pendingList": "내 결재 대기 문서", + "colTitle": "제목", + "colDocType": "유형", + "colDrafter": "기안자", + "colSubmitted": "상신일", + "colStep": "단계", + "colStatus": "상태", + "colAction": "처리", + "open": "열기", + "emptyPending": "결재할 문서가 없습니다", + "status": { + "DRAFT": "임시저장", + "SUBMITTED": "상신", + "IN_PROGRESS": "진행중", + "APPROVED": "승인완료", + "REJECTED": "반려", + "CANCELED": "회수" + }, + "reviewTitle": "결재 검토", + "version": "버전", + "drafter": "기안자", + "step": "단계", + "demoMode": "미리보기(데모)", + "downloadOriginal": "원본 다운로드", + "previewAria": "문서 미리보기", + "lineAria": "결재선·이력", + "docBody": "본문", + "line": "결재선", + "history": "처리 이력", + "noHistory": "처리 이력이 없습니다", + "lineStatus": { + "WAIT": "대기", + "CURRENT": "결재 진행", + "APPROVED": "승인", + "REJECTED": "반려", + "SKIPPED": "건너뜀" + }, + "action": { + "SUBMIT": "상신", + "APPROVE": "승인", + "REJECT": "반려", + "RESUBMIT": "재상신", + "CANCEL": "회수" + }, + "approve": "승인", + "reject": "반려", + "approvedDone": "승인 처리되었습니다.", + "rejectedDone": "반려 처리되었습니다.", + "canActHint": "이 문서의 현재 단계 결재자입니다.", + "notApproverHint": "현재 단계 결재자만 처리할 수 있습니다.", + "demoFootNote": "결재 대기함에서 문서를 선택하면 실제 승인/반려가 가능합니다.", + "demoHint": "결재 문서가 지정되지 않았습니다. 결재 대기함에서 문서를 열어 주세요.", + "commentPlaceholder": "의견(반려 시 필수)을 입력하세요", + "comment": "의견" + }, + "auction": { + "boq": { + "title": "물량서(BOQ) 자동산출", + "error": "물량서를 불러오지 못했습니다.", + "scopeBooth": "{{label}} 부스 기준", + "scopeEvent": "행사 전체 부스 집계", + "summary": "부스 {{count}}실 · {{area}}㎡", + "colSeq": "No", + "colTrade": "공종", + "colItem": "품명", + "colQty": "수량", + "colUnit": "단위", + "colBasis": "산출 근거" + } + }, + "logistics": { + "title": "물류 허브", + "subtitle": "반입·반출 도크 · 부대장비 · 렌탈 · 선입고 트래킹", + "tabsAria": "물류 기능 전환", + "tabs": { + "dock": "도크 예약", + "equipment": "장비 신청", + "rental": "렌탈 주문", + "inbound": "선입고" + }, + "boothNo": "부스 번호", + "qty": "수량", + "estimate": "예상 금액", + "submitting": "처리 중…", + "submitOk": "접수되었습니다.", + "submitErr": "처리에 실패했습니다.", + "statusUpdated": "상태가 변경되었습니다.", + "loadErr": "물류 정보를 불러오지 못했습니다.", + "serverAmountNote": "금액은 서버 기준으로 확정됩니다", + "equipment": { + "formTitle": "부대장비 신청", + "item": "장비 품목", + "days": "사용 일수", + "daysUnit": "일", + "useDate": "사용 예정일", + "submit": "장비 신청", + "listTitle": "신청 내역", + "empty": "신청 내역이 없습니다", + "emptyDesc": "지게차·리프트 등 부대장비를 신청하세요.", + "approve": "승인", + "reject": "반려", + "rejectReason": "현장 여건상 반려", + "done": "처리 완료", + "st": { + "requested": "신청됨", + "approved": "승인", + "rejected": "반려", + "done": "완료" + } + }, + "rental": { + "formTitle": "부대물품 렌탈 주문", + "item": "렌탈 품목", + "start": "시작일", + "end": "종료일", + "submit": "렌탈 주문", + "listTitle": "주문 내역", + "empty": "주문 내역이 없습니다", + "emptyDesc": "테이블·의자·모니터 등을 주문하세요.", + "period": "{{days}}일", + "confirm": "확정", + "cancel": "취소", + "deliver": "납품 완료", + "st": { + "ordered": "주문됨", + "confirmed": "확정", + "cancelled": "취소", + "delivered": "납품" + } + }, + "inbound": { + "formTitle": "선입고 운송장 등록", + "trackingNo": "운송장 번호", + "carrier": "운송사", + "itemDesc": "화물 내용", + "eta": "입고 예정일", + "submit": "운송장 등록", + "submitSub": "등록 후 입고 상태를 추적합니다", + "listTitle": "선입고 현황", + "empty": "선입고 내역이 없습니다", + "emptyDesc": "사전 배송 운송장을 등록하세요.", + "st": { + "registered": "등록됨", + "inbound_scheduled": "입고 예정", + "arrived_warehouse": "창고 도착", + "delivered_booth": "부스 전달" + }, + "advance": { + "inbound_scheduled": "입고 예정 처리", + "arrived_warehouse": "창고 도착 처리", + "delivered_booth": "부스 전달 완료" + } + } } } diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index 460f8b4..a2408bc 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -62,6 +62,12 @@ "contactTel": "总机 1899-1001", "contactEmail": "exhibition@kintex.com", "bottom": "© KINTEX. 版权所有。(10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si" + }, + "footerbar": { + "copyright": "© 2026 KINTEX. 版权所有。", + "systemOk": "系统正常", + "systemDown": "系统维护", + "support": "客户支持" } }, "home": { @@ -101,7 +107,105 @@ "mapAria": "KINTEX 位置地图", "mapAddress": "高阳市一山西区KINTEX路217-60", "mapPlace": "KINTEX 第1、2展馆", - "mapViewAria": "查看地图" + "mapViewAria": "查看地图", + "pageAria": "主页", + "greeting": "您好,{{name}}", + "greetingGuest": "您好", + "viewAll": "查看全部", + "loadError": "加载失败", + "role": { + "organizer": "主办方", + "exhibitor": "参展商", + "contractor": "施工商", + "hallManager": "展馆经理", + "admin": "管理员" + }, + "kpi": { + "aria": "概要指标" + }, + "banner": { + "aria": "活动介绍横幅", + "prev": "上一张", + "next": "下一张", + "emptyTitle": "暂无横幅", + "emptyDesc": "管理员登记的活动横幅将显示在此处。" + }, + "showcase": { + "title": "进行中·即将举办的展会", + "all": "查看全部", + "prev": "上一个", + "next": "下一个", + "statusOngoing": "进行中", + "statusUpcoming": "即将举办", + "emptyTitle": "该期间暂无展会", + "emptyCta": "查看全部日程", + "tab": { + "ongoing": "进行中", + "thisMonth": "本月", + "nextMonth": "下月" + } + }, + "calendar": { + "title": "展会日程", + "prev": "上个月", + "next": "下个月", + "today": "今天", + "all": "全部", + "centerFilter": "展馆筛选", + "yearMonth": "{{y}}年{{m}}月", + "legendOngoing": "进行中", + "legendUpcoming": "即将举办", + "legendEnded": "已结束", + "more": "+{{n}}", + "moreTitle": "{{date}} 展会", + "popupEmpty": "当天没有展会", + "listTitle": "进行中·即将举办", + "listEmpty": "暂无即将举办的展会", + "listEmptyCta": "查看全部日程", + "loadError": "日程加载失败" + }, + "todo": { + "title": "我的待办", + "noDue": "无截止日期", + "emptyTitle": "全部完成", + "emptyDesc": "没有待处理的事项", + "emptyCta": "打开通知" + }, + "notice": { + "title": "公告", + "more": "更多", + "new": "新", + "pinned": "置顶", + "emptyTitle": "暂无公告", + "emptyCta": "查看全部公告" + }, + "quick": { + "title": "快捷操作", + "editor": "布局编辑器", + "invite": "邀请参展商", + "docs": "文件流程", + "settlement": "结算", + "notice": "撰写公告", + "search": "综合搜索" + }, + "activity": { + "title": "最近动态", + "emptyTitle": "暂无最近动态", + "emptyCta": "通知设置" + }, + "time": { + "justNow": "刚刚", + "minutes": "{{n}}分钟前", + "hours": "{{n}}小时前", + "days": "{{n}}天前" + }, + "workspace": { + "title": "我的活动", + "emptyTitle": "尚未参与任何活动", + "emptyDesc": "请确认邀请链接或邀请码", + "invite": "验证邀请码", + "codePh": "输入邀请码" + } }, "detail": { "tabs": { @@ -626,7 +730,63 @@ "day": "{{d}}日", "dayHasEvent": "{{d}}日 (有活动)", "hallUtil": "展厅使用率", - "hallUtilMonth": "展厅使用率 ({{y}}年{{m}}月)" + "hallUtilMonth": "展厅使用率 ({{y}}年{{m}}月)", + "availTitle": "展厅可用情况", + "availMonth": "展厅可用情况 ({{y}}年{{m}}月)", + "availHalls": "共 {{n}} 个展厅", + "availFreeDays": "可用 {{free}}/{{total}} 天", + "availOpen": "可用", + "availFull": "已满" + }, + "boothsales": { + "title": "展位销售", + "subtitleManager": "管理展位销售库存 — 状态、价格、封锁及结算联动", + "subtitleExhibitor": "选择可用展位,先占位再确认购买", + "noEventTitle": "请选择展会", + "noEventDesc": "未选择用于展位销售的展会。", + "summaryAria": "展位销售状态汇总", + "stAvailable": "可售", + "stHeld": "占位中", + "stSold": "已售", + "stBlocked": "已封锁", + "rate": "租金费率 {{rate}} 韩元/㎡ ({{ver}})", + "hallFilter": "展厅筛选", + "allHalls": "全部展厅", + "loadError": "无法加载展位销售库存。", + "emptyTitle": "没有可显示的展位", + "emptyManage": "该展会的布局中未登记展位。", + "emptyExhibitor": "当前没有可选择的展位。", + "status": { + "available": "可售", + "held": "占位中", + "sold": "已售", + "blocked": "已封锁" + }, + "wonUnit": " 韩元", + "hallLabel": "展厅", + "areaLabel": "面积", + "soldTo": "购买方:{{c}}", + "hold": "占位", + "purchase": "确认购买", + "release": "取消占位", + "setPrice": "设置价格", + "block": "封锁", + "unblock": "解除封锁", + "settle": "生成账单", + "settling": "生成中…", + "heldToast": "已占位。请在到期前确认购买。", + "purchasedToast": "购买已确认。", + "releasedToast": "已取消占位。", + "blockToast": "展位状态已更新。", + "priceToast": "销售价格已更新。", + "settleToast": "账单生成完成 — 新增 {{created}} 条,保留 {{skipped}} 条。", + "settleFail": "生成账单失败(请检查权限)。", + "holdConflict": "该展位已被他人占位或已售/已封锁。", + "purchaseConflict": "占位已过期或该展位已被他人占用。", + "blockConflict": "已售展位无法封锁。", + "actionFail": "无法处理请求。", + "pricePrompt": "请输入销售价格(韩元)。留空则恢复为面积×费率自动计算。", + "priceInvalid": "请输入不小于 0 的金额。" }, "settlement": { "title": "结算·付款", @@ -657,7 +817,8 @@ "pending": "未开具", "invoiced": "已开具", "paid": "已付清", - "overdue": "逾期" + "overdue": "逾期", + "cancelled": "已取消" }, "listError": "无法加载账单列表。", "emptyTitle": "暂无账单", @@ -706,7 +867,47 @@ "errPositive": "请输入大于 0 的金额。", "errExceed": "不能超过未收余额。", "pgNote": "实际付款委托外部支付网关处理。本页面仅用于记录收款确认。", - "managerOnly": "仅主办方/展厅经理可记录或开具收款。" + "managerOnly": "仅主办方/展厅经理可记录或开具收款。", + "colCategory": "类型", + "catRental": "租赁·公用", + "catAuctionFee": "拍卖手续费", + "reportTitle": "结算报告", + "reportShow": "查看报告", + "reportHide": "关闭", + "reportHint": "按类别/状态汇总合计、收款率与未收,并支持CSV导出。", + "reportError": "无法加载报告。", + "reportCsv": "下载CSV", + "reportByCategory": "按类别合计", + "reportByStatus": "按状态合计", + "reportEmpty": "没有可汇总的账单。", + "reportNote": "合计不含已取消账单。金额以服务器计算为准。", + "reportRate": "收款率", + "reportCount": "数量", + "taxTitle": "税务发票", + "taxNo": "发票编号", + "taxSupplier": "供应方", + "taxBuyer": "受票方", + "taxItem": "项目", + "taxNone": "尚未开具税务发票。", + "taxIssue": "开具税务发票", + "taxIssuing": "开具中…", + "taxIssueFail": "开具税务发票失败。", + "cancelledNote": "该账单已取消。", + "cancelInvoice": "取消账单", + "refundPreview": "退款测算预览", + "refundError": "无法加载退款测算。", + "refundBracket": "适用区间", + "refundRate": "退款比例", + "refundBase": "基准额(已付)", + "refundAmount": "退款额", + "forfeitAmount": "扣除·违约金", + "refundManual": "因开展日未确定等无法自动测算 — 需财务人工审核。", + "cancelReason": "取消原因", + "cancelReasonPh": "取消原因(可选)", + "cancelAbort": "中止", + "cancelConfirm": "确认取消", + "cancelling": "处理中…", + "cancelFail": "取消账单失败。" }, "hallassign": { "title": "展厅分配 · 自动报价", @@ -760,6 +961,48 @@ "deposit": "管理费押金(另计)", "ruleset": "(规则集 {{v}})" }, + "ai": { + "console": { + "title": "AI 控制台", + "subtitle": "用自然语言查询数据并咨询展会规定。结果仅供参考,并标注依据。", + "badge": "仅供参考", + "tabsAria": "AI 控制台标签", + "tabNlquery": "自然语言查询", + "tabRules": "规定聊天机器人" + }, + "common": { + "running": "执行中…", + "provClaude": "Claude 回复", + "provOllama": "Ollama 回复", + "provRules": "基于规定原文", + "provNone": "无回复", + "unavailable": "AI 功能暂时不可用,请稍后重试。", + "invalid": "该查询违反安全规则,无法执行。", + "forbidden": "您无权使用此功能。", + "error": "处理请求时发生错误。" + }, + "nlquery": { + "placeholder": "例如:显示状态为 active 的活动列表", + "inputAria": "自然语言查询问题", + "run": "查询", + "guardNote": "仅执行只读 SELECT,不查询个人信息列。最多 200 行。", + "rowCount": "{{n}} 行", + "showSql": "查看生成的 SQL", + "hideSql": "隐藏生成的 SQL", + "sqlAria": "生成的 SQL", + "emptyTitle": "无结果", + "emptyDesc": "没有符合条件的数据。请更换问题后重试。" + }, + "rules": { + "placeholder": "例如:装置物的最大高度是多少?", + "inputAria": "规定问题", + "run": "提问", + "groundNote": "回答以规定与费率规则集为依据;若无依据将如实告知。", + "citations": "依据规定", + "notFound": "在规定中未找到", + "degraded": "规定原文摘要(AI 不可用)" + } + }, "analytics": { "title": "经营分析", "subtitle": "KINTEX 运营盈利能力 · 参展商洞察", @@ -1278,5 +1521,164 @@ "updateFail": "变更失败。", "deleteFail": "删除失败。", "deleteConfirm": "确定删除幻灯片 \"{{title}}\" 吗?" + }, + "approval": { + "queueTitle": "审批收件箱", + "search": "搜索", + "searchPlaceholder": "搜索标题...", + "pendingCount": "待审批", + "pendingList": "待我审批的文档", + "colTitle": "标题", + "colDocType": "类型", + "colDrafter": "起草人", + "colSubmitted": "提交日", + "colStep": "步骤", + "colStatus": "状态", + "colAction": "操作", + "open": "打开", + "emptyPending": "没有待审批的文档", + "status": { + "DRAFT": "草稿", + "SUBMITTED": "已提交", + "IN_PROGRESS": "进行中", + "APPROVED": "已批准", + "REJECTED": "已驳回", + "CANCELED": "已撤回" + }, + "reviewTitle": "审批审查", + "version": "版本", + "drafter": "起草人", + "step": "步骤", + "demoMode": "预览(演示)", + "downloadOriginal": "下载原件", + "previewAria": "文档预览", + "lineAria": "审批线与历史", + "docBody": "正文", + "line": "审批线", + "history": "处理历史", + "noHistory": "暂无处理历史", + "lineStatus": { + "WAIT": "等待", + "CURRENT": "审批中", + "APPROVED": "已批准", + "REJECTED": "已驳回", + "SKIPPED": "已跳过" + }, + "action": { + "SUBMIT": "提交", + "APPROVE": "批准", + "REJECT": "驳回", + "RESUBMIT": "重新提交", + "CANCEL": "撤回" + }, + "approve": "批准", + "reject": "驳回", + "approvedDone": "已批准。", + "rejectedDone": "已驳回。", + "canActHint": "您是该文档当前步骤的审批人。", + "notApproverHint": "仅当前步骤审批人可操作。", + "demoFootNote": "从审批收件箱选择文档即可批准或驳回。", + "demoHint": "未指定审批文档,请从审批收件箱打开。", + "commentPlaceholder": "输入意见(驳回时必填)", + "comment": "意见" + }, + "auction": { + "boq": { + "title": "工程量清单(BOQ)自动生成", + "error": "无法加载工程量清单。", + "scopeBooth": "{{label}} 展位", + "scopeEvent": "全场展位汇总", + "summary": "展位 {{count}}个 · {{area}}㎡", + "colSeq": "序号", + "colTrade": "工种", + "colItem": "品名", + "colQty": "数量", + "colUnit": "单位", + "colBasis": "计算依据" + } + }, + "logistics": { + "title": "物流中心", + "subtitle": "进出场月台 · 附加设备 · 租赁 · 预入库追踪", + "tabsAria": "切换物流功能", + "tabs": { + "dock": "月台预约", + "equipment": "设备申请", + "rental": "租赁订单", + "inbound": "预入库" + }, + "boothNo": "展位号", + "qty": "数量", + "estimate": "预估金额", + "submitting": "处理中…", + "submitOk": "已受理。", + "submitErr": "处理失败。", + "statusUpdated": "状态已更新。", + "loadErr": "无法加载物流信息。", + "serverAmountNote": "金额以服务器为准确定", + "equipment": { + "formTitle": "附加设备申请", + "item": "设备品目", + "days": "使用天数", + "daysUnit": "天", + "useDate": "预计使用日", + "submit": "申请设备", + "listTitle": "申请记录", + "empty": "暂无申请记录", + "emptyDesc": "申请叉车、升降机等附加设备。", + "approve": "批准", + "reject": "驳回", + "rejectReason": "因现场条件驳回", + "done": "处理完成", + "st": { + "requested": "已申请", + "approved": "已批准", + "rejected": "已驳回", + "done": "已完成" + } + }, + "rental": { + "formTitle": "附加物品租赁订单", + "item": "租赁品目", + "start": "开始日期", + "end": "结束日期", + "submit": "下单租赁", + "listTitle": "订单记录", + "empty": "暂无订单记录", + "emptyDesc": "订购桌椅、显示器等。", + "period": "{{days}}天", + "confirm": "确认", + "cancel": "取消", + "deliver": "交付完成", + "st": { + "ordered": "已下单", + "confirmed": "已确认", + "cancelled": "已取消", + "delivered": "已交付" + } + }, + "inbound": { + "formTitle": "预入库运单登记", + "trackingNo": "运单号", + "carrier": "承运商", + "itemDesc": "货物内容", + "eta": "预计入库日", + "submit": "登记运单", + "submitSub": "登记后追踪入库状态", + "listTitle": "预入库状态", + "empty": "暂无预入库记录", + "emptyDesc": "登记预配送运单号。", + "st": { + "registered": "已登记", + "inbound_scheduled": "待入库", + "arrived_warehouse": "到达仓库", + "delivered_booth": "送达展位" + }, + "advance": { + "inbound_scheduled": "标记待入库", + "arrived_warehouse": "标记到仓", + "delivered_booth": "标记送达展位" + } + } } } diff --git a/src/frontend/src/screens/analytics/AiConsolePanel.tsx b/src/frontend/src/screens/analytics/AiConsolePanel.tsx new file mode 100644 index 0000000..b7c44a5 --- /dev/null +++ b/src/frontend/src/screens/analytics/AiConsolePanel.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../../components/ui/Button'; +import { AiLabel } from '../../components/ui/Badge'; +import { EmptyState } from '../../components/ui/States'; +import { ApiRequestError } from '../../api/client'; +import { aiConsoleApi } from './aiConsoleApi'; +import type { NlQueryResult, RulesChatResult } from './aiConsoleApi'; +import './aiConsole.css'; + +/* + * F092 자연어 조회(Text-to-SQL) + F025 규정 챗봇 콘솔 — SCR-13 경영분석 하위 패널. + * 두 탭 동거: [자연어 조회] 질문→표 결과+생성 SQL 접기, [규정 챗봇] 질문→근거 룰 인용 답변. + * 모든 AI 산출물은 AiLabel + provider/degraded 를 정직하게 표기(참고용 · 환각 방지). + */ +type Tab = 'nlquery' | 'rules'; + +export function AiConsolePanel() { + const { t } = useTranslation(); + const [tab, setTab] = useState('nlquery'); + + return ( +
+
+
+

{t('ai.console.title')}

+

{t('ai.console.subtitle')}

+
+ {t('ai.console.badge')} +
+ +
+ + +
+ + {tab === 'nlquery' ? : } +
+ ); +} + +// ── 탭 1: 자연어 조회(Text-to-SQL) ── +function NlQueryTab() { + const { t } = useTranslation(); + const [q, setQ] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [showSql, setShowSql] = useState(true); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!q.trim() || loading) return; + setLoading(true); + setError(null); + setResult(null); + try { + const r = await aiConsoleApi.nlquery(q.trim()); + setResult(r); + } catch (err) { + setError(errorMessage(err, t)); + } finally { + setLoading(false); + } + } + + return ( +
+
+ setQ(e.target.value)} + placeholder={t('ai.nlquery.placeholder')} + aria-label={t('ai.nlquery.inputAria')} + /> + +
+

{t('ai.nlquery.guardNote')}

+ + {error &&
{error}
} + + {result && ( +
+
+ {providerLabel(result.provider, t)} + {t('ai.nlquery.rowCount', { n: result.rowCount })} + +
+ + {showSql && ( +
+              {result.sql}
+            
+ )} + + {result.rowCount === 0 ? ( + + ) : ( +
+ + + + {result.columns.map((c) => ( + + ))} + + + + {result.rows.map((row, i) => ( + + {result.columns.map((c) => ( + + ))} + + ))} + +
{c}
{cell(row[c])}
+
+ )} +
+ )} +
+ ); +} + +// ── 탭 2: 규정 챗봇 ── +function RulesChatTab() { + const { t } = useTranslation(); + const [q, setQ] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!q.trim() || loading) return; + setLoading(true); + setError(null); + setResult(null); + try { + const r = await aiConsoleApi.rulesChat(q.trim()); + setResult(r); + } catch (err) { + setError(errorMessage(err, t)); + } finally { + setLoading(false); + } + } + + return ( +
+
+ setQ(e.target.value)} + placeholder={t('ai.rules.placeholder')} + aria-label={t('ai.rules.inputAria')} + /> + +
+

{t('ai.rules.groundNote')}

+ + {error &&
{error}
} + + {result && ( +
+
+ {providerLabel(result.provider, t)} + {!result.grounded && ( + {t('ai.rules.notFound')} + )} + {result.degraded && result.grounded && ( + {t('ai.rules.degraded')} + )} +
+ +
{result.answer}
+ + {result.citations.length > 0 && ( +
+ {t('ai.rules.citations')} +
    + {result.citations.map((c) => ( +
  • + {c.code} {c.label} + {c.detail && · {c.detail}} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +} + +// ── helpers ── +function cell(v: unknown): string { + if (v === null || v === undefined) return '—'; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); +} + +function providerLabel(provider: string, t: (k: string) => string): string { + if (provider === 'claude') return t('ai.common.provClaude'); + if (provider === 'ollama') return t('ai.common.provOllama'); + if (provider === 'rules') return t('ai.common.provRules'); + return t('ai.common.provNone'); +} + +function errorMessage(err: unknown, t: (k: string) => string): string { + if (err instanceof ApiRequestError) { + // err.code 는 백엔드 오류코드 문자열(AI_UNAVAILABLE 은 공용 타입 유니온 밖 — string 비교). + const code = err.code as string; + if (code === 'AI_UNAVAILABLE') return t('ai.common.unavailable'); + if (code === 'VALIDATION') return err.message || t('ai.common.invalid'); + if (code === 'FORBIDDEN') return t('ai.common.forbidden'); + return err.message || t('ai.common.error'); + } + return t('ai.common.error'); +} diff --git a/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx b/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx index 36e75be..5ae6437 100644 --- a/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx +++ b/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx @@ -31,6 +31,7 @@ import { SECTORS, } from './sampleAnalytics'; import { GlobalOverviewSection } from './GlobalOverviewSection'; +import { AiConsolePanel } from './AiConsolePanel'; import './analytics.css'; /* @@ -250,6 +251,9 @@ export function AnalyticsDashboardPage() { {/* 테넌트 전역(행사 횡단) BI — 홀매니저 전용. 권한/오프라인 시 내부에서 숨김. */} + + {/* F092 자연어 조회(Text-to-SQL) + F025 규정 챗봇 — AI 라우터 경유(참고용·근거 표기). */} + ); } diff --git a/src/frontend/src/screens/analytics/aiConsole.css b/src/frontend/src/screens/analytics/aiConsole.css new file mode 100644 index 0000000..b3cbfb0 --- /dev/null +++ b/src/frontend/src/screens/analytics/aiConsole.css @@ -0,0 +1,146 @@ +@import '../shared.css'; + +/* F092/F025 AI 콘솔 패널 — SCR-13 하위. 전역 재정의 방지 위해 .kx-aicon 로 스코프 격리. */ +.kx-aicon { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.kx-aicon__pane { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.kx-aicon__ask { + display: flex; + gap: var(--space-2); + align-items: center; +} +.kx-aicon .kx-input { + height: 38px; + flex: 1; + min-width: 0; + padding: 0 var(--space-3); + border: var(--border-card); + border-radius: var(--radius-sm); + background: var(--color-white); + font-size: var(--fs-body); + color: var(--color-neutral-900); +} +.kx-aicon .kx-input:focus { + outline: 2px solid var(--color-ai-accent); + outline-offset: 1px; +} + +.kx-aicon__note { + font-size: var(--fs-caption, 12px); + color: var(--color-neutral-500); +} + +.kx-aicon__error { + padding: var(--space-3); + border-radius: var(--radius-sm); + background: #FEF3F2; + border: 1px solid #FDA29B; + color: #B42318; + font-size: var(--fs-body); +} + +.kx-aicon__result { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.kx-aicon__meta { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + font-size: var(--fs-caption, 12px); +} +.kx-aicon__prov { + padding: 2px 8px; + border-radius: var(--radius-pill); + background: var(--color-neutral-100, #F2F4F7); + color: var(--color-neutral-700, #344054); +} +.kx-aicon__count { + color: var(--color-neutral-500); +} +.kx-aicon__toggle { + margin-left: auto; + background: none; + border: none; + color: var(--color-ai-accent); + cursor: pointer; + font-size: var(--fs-caption, 12px); + text-decoration: underline; +} +.kx-aicon__ungrounded { + color: #B54708; + font-weight: 600; +} +.kx-aicon__degraded { + color: var(--color-neutral-500); +} + +.kx-aicon__sql { + margin: 0; + padding: var(--space-3); + background: #0B1021; + color: #C7D2FE; + border-radius: var(--radius-sm); + overflow-x: auto; + font-size: 12.5px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} +.kx-aicon__sql code { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); +} + +.kx-aicon__answer { + padding: var(--space-3); + background: var(--color-neutral-50, #F9FAFB); + border-radius: var(--radius-sm); + font-size: var(--fs-body); + color: var(--color-neutral-900); + white-space: pre-wrap; + line-height: 1.6; +} + +.kx-aicon__cites { + font-size: var(--fs-caption, 12px); +} +.kx-aicon__cites-h { + display: block; + font-weight: 600; + color: var(--color-neutral-700, #344054); + margin-bottom: var(--space-1, 4px); +} +.kx-aicon__cites ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); +} +.kx-aicon__cites li { + color: var(--color-neutral-700, #344054); +} +.kx-aicon__code { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: rgba(124, 58, 237, 0.1); + color: var(--color-ai-accent); + font-size: 11.5px; +} +.kx-aicon__cite-detail { + color: var(--color-neutral-500); +} diff --git a/src/frontend/src/screens/analytics/aiConsoleApi.ts b/src/frontend/src/screens/analytics/aiConsoleApi.ts new file mode 100644 index 0000000..54683a9 --- /dev/null +++ b/src/frontend/src/screens/analytics/aiConsoleApi.ts @@ -0,0 +1,38 @@ +/* + * F092 자연어 조회(Text-to-SQL) + F025 규정 챗봇 로컬 API. + * 백엔드: POST /api/ai/nlquery · POST /api/ai/rules-chat (com.zioinfo.kintex.ai.nlquery). + * 공용 api/{client,endpoints,types}는 수정하지 않고 client의 `api`만 재사용한다. + */ +import { api } from '../../api/client'; + +export interface NlQueryResult { + question: string; + sql: string; + tables: string[]; + columns: string[]; + rows: Record[]; + rowCount: number; + provider: string; +} + +export interface RuleCitation { + code: string; + label: string; + detail: string; +} + +export interface RulesChatResult { + question: string; + answer: string; + citations: RuleCitation[]; + grounded: boolean; + provider: string; + degraded: boolean; +} + +export const aiConsoleApi = { + nlquery: (question: string) => + api.post('/api/ai/nlquery', { question }), + rulesChat: (question: string) => + api.post('/api/ai/rules-chat', { question }), +}; diff --git a/src/frontend/src/screens/approval/ManagerApprovalQueuePage.tsx b/src/frontend/src/screens/approval/ManagerApprovalQueuePage.tsx index d6acea7..6c3b4d8 100644 --- a/src/frontend/src/screens/approval/ManagerApprovalQueuePage.tsx +++ b/src/frontend/src/screens/approval/ManagerApprovalQueuePage.tsx @@ -1,184 +1,143 @@ import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { Button } from '../../components/ui/Button'; -import { DdayChip } from '../../components/ui/Badge'; -import { IconCheck, IconSearch, IconSpark, IconWarning } from '../../components/ui/icons'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { IconSearch } from '../../components/ui/icons'; +import { approvalApi, type ApprovalStatus } from './approvalApi'; import './approval-queue.css'; /* - * SCR-10 검수 대기열(홀매니저). Stitch manager_approval_queue 이식. - * - 홀 탭 + 요약 스탯(대기·오늘마감·AI 차단) + 검색 · 서류 테이블(AI 사전심사 배지) · 우: 홀 부하/AI 인사이트 - * ★ 계약에 문서 검수 큐 엔드포인트 부재 → 샘플 시연. '검수'→ SCR-11 실 라우트. + * SCR-10 결재 대기열(홀매니저·결재자). §5B 공통 전자결재(F028) 실배선. + * - 샘플 큐를 실 결재함 API(box=PENDING: 내가 현재 단계 결재자인 진행중 문서)로 전환. + * - '검수/열기' → ReviewDetailsPage(승인/반려 실동작). 라우트 미변경: approvalId 쿼리로 전달. */ -interface DocRow { - id: string; - event: string; - booth: string; - docType: string; - submitter: string; - submittedAt: string; - dday: number; - ai: 'block' | 'pass'; - aiCount: number; - status: string; +function statusTone(s: ApprovalStatus): string { + switch (s) { + case 'APPROVED': + return 'kx-aq__st--ok'; + case 'REJECTED': + return 'kx-aq__st--bad'; + case 'IN_PROGRESS': + case 'SUBMITTED': + return 'kx-aq__st--wip'; + default: + return ''; + } } -const ROWS: DocRow[] = [ - { id: 'd1', event: '스마트팩토리 코리아', booth: 'A-102', docType: '장치 도면', submitter: '(주)공간디자인', submittedAt: '07.10', dday: 6, ai: 'block', aiCount: 1, status: '검토 대기' }, - { id: 'd2', event: '스마트팩토리 코리아', booth: 'B-201', docType: '유틸리티 위치표시도', submitter: '미래테크(주)', submittedAt: '07.11', dday: 14, ai: 'pass', aiCount: 0, status: '검토 대기' }, - { id: 'd3', event: '스마트팩토리 코리아', booth: 'C-118', docType: '방염 확인서', submitter: '한빛로보틱스', submittedAt: '07.11', dday: 9, ai: 'pass', aiCount: 0, status: '검토 대기' }, - { id: 'd4', event: '스마트팩토리 코리아', booth: 'A-330', docType: '구조계산서', submitter: '(주)대한전시', submittedAt: '07.09', dday: 3, ai: 'block', aiCount: 2, status: '검토 대기' }, -]; - -const HALLS = ['홀6', '홀7', '홀8']; - export function ManagerApprovalQueuePage() { - const { eventId = '', hallId = 'H7' } = useParams(); + const { eventId = '', hallId = 'H7', booth = 'A-101' } = useParams(); const navigate = useNavigate(); - const [hall, setHall] = useState('홀7'); + const { t } = useTranslation(); + const [keyword, setKeyword] = useState(''); + const [term, setTerm] = useState(''); + + const listQ = useQuery({ + queryKey: ['approvals', 'PENDING', term], + queryFn: () => approvalApi.list({ box: 'PENDING', keyword: term || undefined, page: 0, size: 100 }), + }); + const rows = listQ.data?.items ?? []; + + const statusLabel = (s: ApprovalStatus) => t(`approval.status.${s}`); return (
- {/* 서브 헤더 */}
-

도면 및 서류 검수 대기열

-
- {HALLS.map((h) => ( - - ))} -
-
-
- - +

{t('approval.queueTitle')}

+
{ + e.preventDefault(); + setTerm(keyword.trim()); + }} + > + + setKeyword(e.target.value)} + /> +
- 검수 대기 - 23 -
-
- 오늘 마감 - 5 -
-
- AI 차단 플래그 - 4 + {t('approval.pendingCount')} + {listQ.data?.total ?? 0}
- {/* 테이블 */} -
+
-

제출 서류 목록 ({hall})

- +

{t('approval.pendingList')}

- - - - - - - - - - - - - - - - {ROWS.map((r) => ( - - - - - - - - - - + {listQ.isLoading ? ( +
+ + + +
+ ) : listQ.isError ? ( + listQ.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
행사/부스서류 유형제출자제출일마감AI 사전심사상태액션
-
{r.event}
-
{r.booth}
-
{r.docType}{r.submitter}{r.submittedAt} - {r.ai === 'block' ? ( - 차단 {r.aiCount} - ) : ( - 통과 - )} - {r.status} - -
+ + + + + + + + + - ))} - -
{t('approval.colTitle')}{t('approval.colDocType')}{t('approval.colDrafter')}{t('approval.colSubmitted')}{t('approval.colStep')}{t('approval.colStatus')}{t('approval.colAction')}
+ + + {rows.map((r) => ( + + +
{r.title}
+ {r.refId ?
{r.refId}
: null} + + {r.docType} + {r.drafterName ?? r.drafterId} + {(r.submittedAt ?? '').slice(0, 10)} + + {r.curStep}/{r.totalSteps} + + + {statusLabel(r.status)} + + + + + + ))} + + + )}
- - {/* 우 사이드 */} -
); diff --git a/src/frontend/src/screens/approval/ReviewDetailsPage.tsx b/src/frontend/src/screens/approval/ReviewDetailsPage.tsx index 6c7dd95..473841f 100644 --- a/src/frontend/src/screens/approval/ReviewDetailsPage.tsx +++ b/src/frontend/src/screens/approval/ReviewDetailsPage.tsx @@ -1,7 +1,10 @@ import { useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { Button } from '../../components/ui/Button'; import { AiLabel } from '../../components/ui/Badge'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { IconChevronRight, IconDownload, @@ -10,83 +13,132 @@ import { IconPlus, IconSpark, } from '../../components/ui/icons'; +import { approvalApi, type ApprovalLine } from './approvalApi'; import './review-details.css'; /* - * SCR-11 검수 상세. Stitch review_details 이식. - * - 좌 70% 다크 캔버스(도면 + AI 위반 핀 오버레이, 탭·줌 컨트롤) / 우 30% AI 사전심사 결과 + 협의 이력 - * - 하단: 반려/승인 (모든 위반 '확인함' 체크 시 승인 활성) - * ★ 계약에 검수 상세 엔드포인트 부재 → 샘플. 승인/반려는 로컬 처리(백엔드 연동 준비). + * SCR-11 검수 상세. §5B 공통 전자결재(F028) 실배선. + * - 좌: 문서/도면 프리뷰(참고용 샘플 캔버스 유지) / 우: 실 결재선·이력·의견 + 승인/반려(실동작). + * - approvalId 쿼리 존재 시 실 결재 문서 로드·처리(승인/반려는 현재 단계 결재자만 백엔드 403). + * 미존재 시 기존 데모(도면·AI 위반 오버레이) 유지 — 라우트 하위호환. */ const MOCK = `${import.meta.env.BASE_URL}mock/review_details`; const BLUEPRINT = `${MOCK}/img1.jpg`; -interface V { - pin: number; - title: string; - detail: string; -} -const VIOLATIONS: V[] = [ - { pin: 1, title: '높이 규정 위반 (목공 벽체)', detail: '제출된 도면의 후면 벽체 높이가 4.5m로, 해당 구역 제한 높이(4.0m)를 초과함.' }, - { pin: 2, title: '통로 여유 공간 부족', detail: '우측 전시물 배치가 지정된 통로 경계선으로부터 500mm 여유 공간을 확보하지 않음.' }, - { pin: 3, title: '소화기 배치 위치 불명확', detail: '안전 규정에 따른 필수 소화기 비치 위치가 도면 상에 명시되지 않음.' }, -]; - const TABS = ['도면', '예상 사진 (참고용)', '버전 비교']; -const PIN_POS = [ - { top: '30%', left: '45%' }, - { top: '60%', left: '20%' }, - { top: '50%', left: '68%' }, -]; + +function lineTone(s: ApprovalLine['lineStatus']): string { + switch (s) { + case 'CURRENT': + return 'kx-rev__step--cur'; + case 'APPROVED': + return 'kx-rev__step--ok'; + case 'REJECTED': + return 'kx-rev__step--bad'; + case 'SKIPPED': + return 'kx-rev__step--skip'; + default: + return ''; + } +} export function ReviewDetailsPage() { const { booth = 'A-102' } = useParams(); + const [sp] = useSearchParams(); + const approvalId = sp.get('approvalId'); const navigate = useNavigate(); + const qc = useQueryClient(); + const { t } = useTranslation(); const [tab, setTab] = useState(0); const [overlay, setOverlay] = useState(true); - const [checked, setChecked] = useState>({}); - const [decision, setDecision] = useState<'approved' | 'rejected' | null>(null); + const [comment, setComment] = useState(''); + const [notice, setNotice] = useState(null); - const allChecked = VIOLATIONS.every((v) => checked[v.pin]); + const detailQ = useQuery({ + queryKey: ['approval', approvalId], + queryFn: () => approvalApi.get(approvalId as string), + enabled: !!approvalId, + }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['approval', approvalId] }); + qc.invalidateQueries({ queryKey: ['approvals'] }); + }; + + const approveM = useMutation({ + mutationFn: () => approvalApi.approve(approvalId as string, comment.trim() || undefined), + onSuccess: () => { + setNotice(t('approval.approvedDone')); + setComment(''); + invalidate(); + }, + onError: (e: Error) => setNotice(e.message), + }); + + const rejectM = useMutation({ + mutationFn: () => approvalApi.reject(approvalId as string, comment.trim()), + onSuccess: () => { + setNotice(t('approval.rejectedDone')); + setComment(''); + invalidate(); + }, + onError: (e: Error) => setNotice(e.message), + }); + + const d = detailQ.data; + const busy = approveM.isPending || rejectM.isPending; + const statusLabel = d ? t(`approval.status.${d.status}`) : ''; return (
- {/* 헤더 */}
-

장치 도면 검토

- 버전: v2 - 제출자: (주)공간디자인 - 일자: 07.10 +

{d ? d.title : t('approval.reviewTitle')}

+ {d ? ( + <> + + {t('approval.version')}: v{d.version} + + + {t('approval.drafter')}: {d.drafterName ?? d.drafterId} + + {statusLabel} + + {t('approval.step')} {d.curStep}/{d.totalSteps} + + + ) : ( + {t('approval.demoMode')} + )}
- {/* 캔버스 */} -
+ {/* 좌: 문서/도면 프리뷰(참고용) */} +
- {TABS.map((t, i) => ( + {TABS.map((tabLabel, i) => ( ))}
@@ -95,110 +147,140 @@ export function ReviewDetailsPage() { onClick={() => setOverlay((o) => !o)} aria-pressed={overlay} > - AI 위반 오버레이 + {' '} + AI 오버레이
-
- {/* 우 패널 */} -
- {/* 하단 액션 */} + {/* 하단 액션 — 실 승인/반려(현재 단계 결재자만) */}
-

- {decision === 'approved' - ? '승인 처리되었습니다.' - : decision === 'rejected' - ? '반려 처리되었습니다 — 제출자에게 통보됩니다.' - : allChecked - ? '모든 위반 항목을 확인했습니다.' - : '위반 항목을 모두 확인해야 승인할 수 있습니다.'} -

+
+

+ {notice + ? notice + : approvalId && d + ? d.canAct + ? t('approval.canActHint') + : t('approval.notApproverHint') + : t('approval.demoFootNote')} +

+ {approvalId && d?.canAct ? ( + setComment(e.target.value)} + /> + ) : null} +
- -
diff --git a/src/frontend/src/screens/approval/approval-queue.css b/src/frontend/src/screens/approval/approval-queue.css index 07cbaa4..c16e5d7 100644 --- a/src/frontend/src/screens/approval/approval-queue.css +++ b/src/frontend/src/screens/approval/approval-queue.css @@ -288,3 +288,33 @@ display: none; } } + +/* F028 실배선 — 상태 배지·스켈레톤 */ +.kx-aq__skel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} +.kx-aq__st { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: rgba(148, 163, 184, 0.16); + color: #475569; +} +.kx-aq__st--wip { + background: rgba(31, 41, 252, 0.12); + color: #1f29fc; +} +.kx-aq__st--ok { + background: rgba(16, 185, 129, 0.14); + color: #047857; +} +.kx-aq__st--bad { + background: rgba(239, 68, 68, 0.14); + color: #b91c1c; +} diff --git a/src/frontend/src/screens/approval/approvalApi.ts b/src/frontend/src/screens/approval/approvalApi.ts new file mode 100644 index 0000000..3f2dee7 --- /dev/null +++ b/src/frontend/src/screens/approval/approvalApi.ts @@ -0,0 +1,134 @@ +/* + * §5B 공통 전자결재(F028) API 클라이언트 — 자체 모듈. + * ★ 공유 api/endpoints.ts·types.ts 를 수정하지 않기 위해 결재 엔드포인트/타입을 여기에 캡슐화한다. + * 공용 client.ts 의 api(get/post) 만 재사용하여 JWT·에러 봉투 처리를 공유한다. + * 백엔드: /api/work/approvals (ApprovalController). 상태: DRAFT→SUBMITTED→IN_PROGRESS→APPROVED|REJECTED(+CANCELED). + */ +import { api } from '../../api/client'; +import type { PageResponse } from '../../api/types'; + +export type ApprovalStatus = + | 'DRAFT' + | 'SUBMITTED' + | 'IN_PROGRESS' + | 'APPROVED' + | 'REJECTED' + | 'CANCELED'; + +export type ApprovalBox = 'DRAFT' | 'DRAFTED' | 'PENDING' | 'DONE'; + +export interface ApprovalListItem { + id: string; + docType: string; + title: string; + status: ApprovalStatus; + drafterId: string; + drafterName: string | null; + curStep: number; + totalSteps: number; + version: number; + eventId: string | null; + refType: string | null; + refId: string | null; + submittedAt: string | null; + createdAt: string | null; +} + +export interface ApprovalLine { + id: string; + stepNo: number; + approverId: string; + approverName: string | null; + lineStatus: 'WAIT' | 'CURRENT' | 'APPROVED' | 'REJECTED' | 'SKIPPED'; + actedAt: string | null; + comment: string | null; +} + +export interface ApprovalHistoryItem { + id: string; + stepNo: number | null; + actorId: string; + actorName: string | null; + action: 'SUBMIT' | 'APPROVE' | 'REJECT' | 'RESUBMIT' | 'CANCEL'; + comment: string | null; + actedAt: string | null; +} + +export interface ApprovalDetail { + id: string; + docType: string; + title: string; + content: string | null; + status: ApprovalStatus; + drafterId: string; + drafterName: string | null; + curStep: number; + totalSteps: number; + version: number; + eventId: string | null; + refType: string | null; + refId: string | null; + submittedAt: string | null; + completedAt: string | null; + createdAt: string | null; + lines: ApprovalLine[]; + history: ApprovalHistoryItem[]; + canAct: boolean; + canCancel: boolean; + canResubmit: boolean; +} + +export interface ApprovalLineSaveRequest { + stepNo: number; + approverId: string; + approverName?: string; +} + +export interface ApprovalSaveRequest { + eventId?: string; + docType?: string; + title: string; + content?: string; + refType?: string; + refId?: string; + lines?: ApprovalLineSaveRequest[]; +} + +const BASE = '/api/work/approvals'; + +export const approvalApi = { + /** GET / — 결재함 목록(box=DRAFT|DRAFTED|PENDING|DONE). */ + list: (params?: { box?: ApprovalBox; keyword?: string; page?: number; size?: number }) => { + const q = new URLSearchParams(); + if (params?.box) q.set('box', params.box); + if (params?.keyword) q.set('keyword', params.keyword); + if (params?.page != null) q.set('page', String(params.page)); + if (params?.size != null) q.set('size', String(params.size)); + const qs = q.toString(); + return api.get>(`${BASE}${qs ? `?${qs}` : ''}`); + }, + + /** GET /{id} — 상세(결재선·이력·액션 플래그). */ + get: (id: string) => api.get(`${BASE}/${encodeURIComponent(id)}`), + + /** POST / — 기안(DRAFT). */ + create: (body: ApprovalSaveRequest) => api.post(BASE, body), + + /** POST /{id}/submit — 상신. */ + submit: (id: string) => api.post(`${BASE}/${encodeURIComponent(id)}/submit`), + + /** POST /{id}/approve — 승인(현재 단계 결재자). */ + approve: (id: string, comment?: string) => + api.post(`${BASE}/${encodeURIComponent(id)}/approve`, { comment }), + + /** POST /{id}/reject — 반려(사유 필수). */ + reject: (id: string, comment: string) => + api.post(`${BASE}/${encodeURIComponent(id)}/reject`, { comment }), + + /** POST /{id}/resubmit — 재상신(version+1). */ + resubmit: (id: string, body?: ApprovalSaveRequest) => + api.post(`${BASE}/${encodeURIComponent(id)}/resubmit`, body ?? {}), + + /** POST /{id}/cancel — 회수. */ + cancel: (id: string) => api.post(`${BASE}/${encodeURIComponent(id)}/cancel`), +}; diff --git a/src/frontend/src/screens/approval/review-details.css b/src/frontend/src/screens/approval/review-details.css index 9e94d3c..9b52723 100644 --- a/src/frontend/src/screens/approval/review-details.css +++ b/src/frontend/src/screens/approval/review-details.css @@ -367,3 +367,66 @@ display: flex; gap: var(--space-3); } + +/* F028 실배선 — 결재선 단계·의견 입력 */ +.kx-rev__foot-left { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 0; +} +.kx-rev__comment-input { + width: 100%; + max-width: 520px; + padding: 8px 12px; + border: var(--border-card); + border-radius: 8px; + font-size: var(--fs-caption); +} +.kx-rev__steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.kx-rev__step { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 8px 10px; + border-radius: 8px; + background: var(--color-neutral-50, #f8fafc); +} +.kx-rev__step-no { + flex: 0 0 22px; + height: 22px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + background: rgba(148, 163, 184, 0.2); + color: #475569; +} +.kx-rev__step--cur { + background: rgba(31, 41, 252, 0.08); +} +.kx-rev__step--cur .kx-rev__step-no { + background: #1f29fc; + color: #fff; +} +.kx-rev__step--ok .kx-rev__step-no { + background: #10b981; + color: #fff; +} +.kx-rev__step--bad .kx-rev__step-no { + background: #ef4444; + color: #fff; +} +.kx-rev__step--skip { + opacity: 0.6; +} diff --git a/src/frontend/src/screens/auction/AuctionDetailPage.tsx b/src/frontend/src/screens/auction/AuctionDetailPage.tsx index 44fba49..bff805e 100644 --- a/src/frontend/src/screens/auction/AuctionDetailPage.tsx +++ b/src/frontend/src/screens/auction/AuctionDetailPage.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { Button } from '../../components/ui/Button'; import { AiLabel } from '../../components/ui/Badge'; import { ErrorState, Skeleton } from '../../components/ui/States'; @@ -66,6 +67,14 @@ export function AuctionDetailPage() { }); const detail = q.data; + // F035 물량서(BOQ) — 자료 탭이 boq 일 때만 서버 규칙 기반 산출 조회. + const boqQ = useQuery({ + queryKey: ['auction', auctionId, 'boq'], + queryFn: () => auctionApi.boq(auctionId), + enabled: !!auctionId && tab === 'boq', + retry: false, + }); + // 카운트다운(로컬 tick) — deadline 기준. detail 갱신 시 재동기화. useEffect(() => { if (!detail?.deadline) return; @@ -178,12 +187,16 @@ export function AuctionDetailPage() { ))}
-
- {tab === 'aiimage' ? : } -

{active.title}

-

{active.desc}

- {active.ai && AI 생성 예상 이미지} -
+ {tab === 'boq' ? ( + boqQ.refetch()} /> + ) : ( +
+ {tab === 'aiimage' ? : } +

{active.title}

+

{active.desc}

+ {active.ai && AI 생성 예상 이미지} +
+ )} {active.ai && AI 생성 예상 이미지}
+ )} + + + {inv && ( +
+ + + + {canManage && } + + {t('boothsales.rate', { rate: WON.format(inv.ratePerM2), ver: inv.rulesetVersion })} + +
+ )} + +
+ +
+ + {toast && ( +
{toast.msg}
+ )} + + {invQ.isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : invQ.isError ? ( + invQ.refetch()} /> + ) : units.length === 0 ? ( +
+ +
+ ) : ( +
+ {units.map((b) => ( + holdM.mutate(b)} + onPurchase={() => purchaseM.mutate(b)} + onRelease={() => releaseM.mutate(b)} + onBlock={(blocked) => blockM.mutate({ b, blocked })} + onPrice={() => onPrice(b)} + /> + ))} +
+ )} +
+ ); +} + +function StatChip({ tone, label, n }: { tone: string; label: string; n: number }) { + return ( + + {n} {label} + + ); +} + +function BoothCard({ + b, canManage, busy, onHold, onPurchase, onRelease, onBlock, onPrice, +}: { + b: BoothSaleUnit; + canManage: boolean; + busy: boolean; + onHold: () => void; + onPurchase: () => void; + onRelease: () => void; + onBlock: (blocked: boolean) => void; + onPrice: () => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ {b.boothNo ?? b.boothId} + {t(`boothsales.status.${b.status}`)} +
+
{WON.format(b.unitPrice)}{t('boothsales.wonUnit')}
+
    +
  • {t('boothsales.hallLabel')}: {b.hallLabel}
  • +
  • {t('boothsales.areaLabel')}: {b.areaM2.toLocaleString()}㎡
  • + {b.status === 'sold' && b.soldToCompany &&
  • {t('boothsales.soldTo', { c: b.soldToCompany })}
  • } +
+ +
+ {/* 참가업체 셀프 판매 흐름 */} + {!canManage && b.status === 'available' && ( + + )} + {!canManage && b.status === 'held' && b.heldByMe && ( + <> + + + + )} + + {/* 주최자 관리 흐름 */} + {canManage && ( + <> + + {b.status !== 'sold' && b.status !== 'blocked' && ( + + )} + {b.status === 'blocked' && ( + + )} + + )} +
+
+ ); +} diff --git a/src/frontend/src/screens/boothsales/boothSalesApi.ts b/src/frontend/src/screens/boothsales/boothSalesApi.ts new file mode 100644 index 0000000..22be954 --- /dev/null +++ b/src/frontend/src/screens/boothsales/boothSalesApi.ts @@ -0,0 +1,82 @@ +/* + * F005/F006 부스 판매 인벤토리·온라인 셀프 판매 실 API 클라이언트. + * 공용 client(../../api/client)의 api 만 사용 — client.ts·endpoints.ts·types.ts 무수정. + * 정본: 백엔드 com.zioinfo.kintex.boothsales (BoothSalesController) 시그니처. + * - GET /api/booth-sales/inventory?eventId=&hallId= → ApiResponse + * - POST /api/booth-sales/hold { eventId, boothId } → BoothSaleResult (이중 hold 409) + * - POST /api/booth-sales/purchase { eventId, boothId, company? } → BoothSaleResult (만료 409) + * - POST /api/booth-sales/release { eventId, boothId } → BoothSaleResult + * - POST /api/booth-sales/block { eventId, boothId, blocked, reason? } → BoothSaleResult (매니저) + * - POST /api/booth-sales/price { eventId, boothId, unitPrice } → BoothSaleResult (매니저) + * - POST /api/booth-sales/settle?eventId= → GenerateResult (매니저, M9 정산 연계) + * + * ★ 금액은 서버 계산이 권위 — 프론트는 서버가 준 원 단위(number)를 표시만 한다(재계산 없음). + */ +import { api } from '../../api/client'; + +export type BoothSaleStatus = 'available' | 'held' | 'sold' | 'blocked'; + +export interface BoothSaleUnit { + boothId: string; + boothNo: string | null; + boothType: string | null; + hallId: string; + hallLabel: string; + areaM2: number; + status: BoothSaleStatus; + unitPrice: number; + heldByMe: boolean; + heldUntil: string | null; + soldToCompany: string | null; +} + +export interface InventoryResponse { + eventId: string; + hallId: string | null; + rulesetVersion: string; + ratePerM2: number; + available: number; + held: number; + sold: number; + blocked: number; + canManage: boolean; + holdTtlMinutes: number; + units: BoothSaleUnit[]; +} + +export interface BoothSaleResult { + action: 'held' | 'purchased' | 'released' | 'blocked' | 'unblocked' | 'priced'; + unit: BoothSaleUnit; +} + +export interface SettleResult { + eventId: string; + created: number; + skipped: number; +} + +export const boothSalesApi = { + inventory: (eventId: string, hallId?: string | null) => { + const qs = new URLSearchParams({ eventId }); + if (hallId) qs.set('hallId', hallId); + return api.get(`/api/booth-sales/inventory?${qs.toString()}`); + }, + + hold: (eventId: string, boothId: string) => + api.post('/api/booth-sales/hold', { eventId, boothId }), + + purchase: (eventId: string, boothId: string, company?: string) => + api.post('/api/booth-sales/purchase', { eventId, boothId, company }), + + release: (eventId: string, boothId: string) => + api.post('/api/booth-sales/release', { eventId, boothId }), + + block: (eventId: string, boothId: string, blocked: boolean, reason?: string) => + api.post('/api/booth-sales/block', { eventId, boothId, blocked, reason }), + + price: (eventId: string, boothId: string, unitPrice: number | null) => + api.post('/api/booth-sales/price', { eventId, boothId, unitPrice }), + + settle: (eventId: string) => + api.post(`/api/booth-sales/settle?eventId=${encodeURIComponent(eventId)}`), +}; diff --git a/src/frontend/src/screens/boothsales/boothsales.css b/src/frontend/src/screens/boothsales/boothsales.css new file mode 100644 index 0000000..3c5be3d --- /dev/null +++ b/src/frontend/src/screens/boothsales/boothsales.css @@ -0,0 +1,138 @@ +/* + * F005/F006 부스 판매 인벤토리·셀프 판매 — 심플 그리드 + 상태 색. + * 디자인 토큰(색·간격·radius)은 전역 변수를 재사용한다(과도한 신규 스타일 지양). + */ +.kx-bsales { + display: flex; + flex-direction: column; + gap: 16px; +} + +.kx-bsales__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.kx-bsales__title { + font-size: 20px; + font-weight: 700; + margin: 0; +} +.kx-bsales__subtitle { + color: var(--kx-text-muted, #64748b); + font-size: 13px; + margin: 4px 0 0; +} + +.kx-bsales__summary { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.kx-bsales__stat { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + background: var(--kx-surface-2, #f1f5f9); + border: 1px solid var(--kx-border, #e2e8f0); +} +.kx-bsales__stat strong { font-size: 15px; font-weight: 700; } +.kx-bsales__stat.is-available { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; } +.kx-bsales__stat.is-held { color: #b45309; background: #fffbeb; border-color: #fde68a; } +.kx-bsales__stat.is-sold { color: #475569; background: #f1f5f9; border-color: #cbd5e1; } +.kx-bsales__stat.is-blocked { color: #b91c1c; background: #fef2f2; border-color: #fecaca; } +.kx-bsales__rate { + margin-left: auto; + font-size: 12px; + color: var(--kx-text-muted, #64748b); +} + +.kx-bsales__toolbar { display: flex; gap: 8px; } + +.kx-bsales__toast { + padding: 10px 14px; + border-radius: 6px; + font-size: 13px; +} +.kx-bsales__toast.is-ok { background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0; } +.kx-bsales__toast.is-err { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; } + +.kx-bsales__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} + +.kx-boothcard { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + border-radius: 8px; + background: var(--kx-surface, #fff); + border: 1px solid var(--kx-border, #e2e8f0); + border-left-width: 4px; +} +.kx-boothcard.is-available { border-left-color: #22c55e; } +.kx-boothcard.is-held { border-left-color: #f59e0b; } +.kx-boothcard.is-sold { border-left-color: #94a3b8; opacity: 0.92; } +.kx-boothcard.is-blocked { border-left-color: #ef4444; opacity: 0.85; } + +.kx-boothcard__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.kx-boothcard__no { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 600; + font-size: 13px; +} +.kx-boothstatus { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; +} +.kx-boothstatus.is-available { color: #15803d; background: #f0fdf4; } +.kx-boothstatus.is-held { color: #b45309; background: #fffbeb; } +.kx-boothstatus.is-sold { color: #475569; background: #f1f5f9; } +.kx-boothstatus.is-blocked { color: #b91c1c; background: #fef2f2; } + +.kx-boothcard__price { + font-size: 22px; + font-weight: 700; + line-height: 1.1; +} +.kx-boothcard__won { font-size: 13px; font-weight: 500; color: var(--kx-text-muted, #64748b); margin-left: 2px; } + +.kx-boothcard__meta { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 3px; + font-size: 12px; + color: var(--kx-text-muted, #64748b); +} + +.kx-boothcard__actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 2px; +} +.kx-boothcard__actions .kx-btn { + padding: 5px 10px; + font-size: 12px; +} diff --git a/src/frontend/src/screens/cms/MultilingualCmsPage.tsx b/src/frontend/src/screens/cms/MultilingualCmsPage.tsx index 6e37284..bfbc98f 100644 --- a/src/frontend/src/screens/cms/MultilingualCmsPage.tsx +++ b/src/frontend/src/screens/cms/MultilingualCmsPage.tsx @@ -36,7 +36,7 @@ const GLOSSARY = [ { ko: '부스', en: 'Booth' }, ]; -const AI_DISABLED_HINT = 'AI 자동 번역(AiTextRouter/Claude)은 아직 배선되지 않았습니다.'; +const AI_TRANSLATE_HINT = 'AI(Claude)로 원문(KO)을 대상 언어로 번역합니다. 초벌은 사람 검수를 거쳐 확정하세요.'; type TransMap = Record>>; @@ -118,6 +118,25 @@ export function MultilingualCmsPage() { setDraftText(transMap[selected.id]?.[target]?.title ?? ''); }, [selected, target, transMap]); + /** AI 자동 번역/재생성(F100 배선) — 원문(KO)→target 초벌(trans_status='ai'). 실패 시 503 메시지 표기(환각 없이 저장 안 함). */ + async function aiTranslate() { + if (!selected || busy) return; + setBusy(true); + setError(null); + try { + const tr = await cmsApi.aiTranslate(selected.id, target); + setTransMap((prev) => ({ + ...prev, + [selected.id]: { ...(prev[selected.id] ?? {}), [tr.lang]: tr }, + })); + setDraftText(tr.title ?? ''); + } catch (e) { + setError(e instanceof ApiRequestError ? e.message : 'AI 자동 번역에 실패했습니다.'); + } finally { + setBusy(false); + } + } + async function save(status: TransStatus) { if (!selected) return; setBusy(true); @@ -186,8 +205,14 @@ export function MultilingualCmsPage() { ))}
- @@ -271,7 +296,13 @@ export function MultilingualCmsPage() { placeholder="번역을 입력하세요" />
-
diff --git a/src/frontend/src/screens/home/HomePage.tsx b/src/frontend/src/screens/home/HomePage.tsx new file mode 100644 index 0000000..4e46c6e --- /dev/null +++ b/src/frontend/src/screens/home/HomePage.tsx @@ -0,0 +1,1066 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; +import { useResolvedEventId } from '../../hooks/useResolvedEventId'; +import { exhibitionApi, loginSlideApi, noticeApi, notificationApi } from '../../api/endpoints'; +import type { ExhibitionDto } from '../../api/types'; +import { DdayChip } from '../../components/ui/Badge'; +import { Button } from '../../components/ui/Button'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { + IconBell, + IconCheckCircle, + IconChevronLeft, + IconChevronRight, + IconDocument, + IconExhibitors, + IconFloorplan, + IconSearch, + IconSettlement, +} from '../../components/ui/icons'; +import { + homeApi, + type HomeTodoItem, + type KpiTile, + type ShowcaseItem, + type ShowcaseTab, +} from './homeApi'; +import { + buildMonthGrid, + eventsOnDay, + type CalEvent, + type CalStatus, +} from './homeCalendar'; +import './home.css'; + +/** 오늘(로컬) — 캘린더 강조·상태 파생 기준. 서버 D-day 는 각 API 가 별도 계산. */ +const TODAY = new Date(); +const TODAY_YMD = ymd(TODAY); + +function ymd(d: Date): string { + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} + +/** 날짜 기준 전시 상태(진행/예정/종료). DB status 원문 대신 오늘 기준 재계산(일정 페이지와 동일 규약). */ +function statusFromDates(start: string, end: string): CalStatus { + if (end < TODAY_YMD) return 'ended'; + if (start > TODAY_YMD) return 'upcoming'; + return 'ongoing'; +} + +/** 홀 라벨이 선택 전시장(센터) 소속인지 — "제{code}전시장" 부분일치. code='all' 이면 전체 통과. */ +function matchesCenter(hallLabel: string | null | undefined, code: string): boolean { + if (code === 'all') return true; + if (!hallLabel) return false; + return new RegExp(`제\\s*${code}\\s*전시장`).test(hallLabel); +} + +export function HomePage() { + const { t } = useTranslation(); + const user = useAuthStore((s) => s.user); + const name = user?.displayName ?? ''; + const dateLabel = useMemo( + () => + TODAY.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + }), + [], + ); + + return ( +
+ {/* ① 인사 + 역할별 KPI (단일 행) */} +
+ +
+ + {/* ② 배너 캐러셀 */} + + + {/* ③ 쇼케이스 포스터 레일 */} + + + {/* ④ 본문 2열 그리드 */} +
+
+ +
+
+ + + + +
+
+ + {/* ⑤ 내 행사 워크스페이스 */} + +
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ① 인사 + KPI 스트립 + * ──────────────────────────────────────────────────────────── */ +function roleLabel(role: string, t: (k: string) => string): string { + switch (role) { + case 'ORGANIZER': + return t('home.role.organizer'); + case 'EXHIBITOR': + return t('home.role.exhibitor'); + case 'CONTRACTOR': + return t('home.role.contractor'); + case 'HALL_MANAGER': + return t('home.role.hallManager'); + case 'ADMIN': + case 'MANAGER': + return t('home.role.admin'); + default: + return role; + } +} + +function kpiValueText(tile: KpiTile): string { + if (tile.value == null) return '—'; + const v = typeof tile.value === 'number' ? tile.value.toLocaleString('ko-KR') : tile.value; + return tile.unit ? `${v}${tile.unit}` : v; +} + +function KpiStrip({ name, dateLabel }: { name: string; dateLabel: string }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const query = useQuery({ queryKey: ['home-summary'], queryFn: () => homeApi.summary() }); + + const role = query.data?.role ?? ''; + const tiles = query.data?.tiles ?? []; + + return ( +
+
+

+ {name ? t('home.greeting', { name }) : t('home.greetingGuest')} +

+

+ {dateLabel} + {role && {roleLabel(role, t)}} +

+
+ +
+ {query.isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + )) + ) : query.isError ? ( +
+ query.refetch()} /> +
+ ) : ( + tiles.map((tile) => ( + + )) + )} +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ② 배너 캐러셀 (login-slides 재사용, 220~260px) + * ──────────────────────────────────────────────────────────── */ +interface BannerSlide { + id: number; + imageUrl: string; + title: string; + caption: string | null; +} +const BANNER_INTERVAL = 5000; + +function BannerCarousel() { + const { t } = useTranslation(); + const [slides, setSlides] = useState(null); + const [index, setIndex] = useState(0); + const [paused, setPaused] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + const ctrl = new AbortController(); + loginSlideApi + .publicList() + .then((list) => { + if (ctrl.signal.aborted) return; + setSlides( + (list ?? []).map((s) => ({ + id: s.id, + imageUrl: s.imageUrl, + title: s.title, + caption: s.caption, + })), + ); + }) + .catch(() => setSlides([])); + return () => ctrl.abort(); + }, []); + + const reduceMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + const count = slides?.length ?? 0; + useEffect(() => { + if (count < 2 || paused || reduceMotion) return; + timerRef.current = window.setInterval( + () => setIndex((i) => (i + 1) % count), + BANNER_INTERVAL, + ); + return () => { + if (timerRef.current != null) window.clearInterval(timerRef.current); + }; + }, [count, paused, reduceMotion]); + + if (slides == null) { + return ( +
+ +
+ ); + } + + if (slides.length === 0) { + // 배너 미등록 — 밀도 원칙 ④: 브랜드 그라디언트 폴백 + CTA + return ( +
+
+
+

{t('home.banner.emptyTitle')}

+

{t('home.banner.emptyDesc')}

+
+
+
+ ); + } + + const cur = slides[index] ?? slides[0]; + const go = (n: number) => setIndex((n + slides.length) % slides.length); + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={() => setPaused(false)} + > + {slides.map((s, i) => ( +
+ ))} +
+
+

+ {cur.title} +

+ {cur.caption && ( +

+ {cur.caption} +

+ )} +
+ + {slides.length > 1 && ( + <> + + +
+ {slides.map((s, i) => ( +
+ + )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ③ 쇼케이스 포스터 레일 + * ──────────────────────────────────────────────────────────── */ +const SHOWCASE_TABS: ShowcaseTab[] = ['ongoing', 'thisMonth', 'nextMonth']; + +function ShowcaseRail() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [tab, setTab] = useState('ongoing'); + const railRef = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + + const query = useQuery({ + queryKey: ['home-showcase', tab], + queryFn: () => homeApi.showcase(tab), + }); + const items = query.data ?? []; + + const updateArrows = useCallback(() => { + const el = railRef.current; + if (!el) return; + setAtStart(el.scrollLeft <= 4); + setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 4); + }, []); + + useEffect(() => { + updateArrows(); + }, [items, updateArrows]); + + const scroll = (dir: number) => { + const el = railRef.current; + if (!el) return; + el.scrollBy({ left: dir * Math.max(320, el.clientWidth * 0.8), behavior: 'smooth' }); + }; + + return ( +
+
+

{t('home.showcase.title')}

+
+ {SHOWCASE_TABS.map((k) => ( + + ))} +
+ +
+ + {query.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : query.isError ? ( +
+ query.refetch()} /> +
+ ) : items.length === 0 ? ( +
+ navigate('/schedule')}>{t('home.showcase.emptyCta')}} + /> +
+ ) : ( +
+ {!atStart && ( + + )} +
+ {items.map((ev) => ( + + ))} +
+ {!atEnd && ( + + )} +
+ )} +
+ ); +} + +/** "2026-08-13"·"2026-08-16" → "08.13–08.16" (동월) / "2026.08.13–2026.09.02" */ +function periodShort(start: string, end: string): string { + const s = start.replace(/-/g, '.').slice(5); + const sameYear = start.slice(0, 4) === end.slice(0, 4); + const sameMonth = sameYear && start.slice(5, 7) === end.slice(5, 7); + const e = sameMonth ? end.slice(8, 10) : end.replace(/-/g, '.').slice(5); + return `${s}–${e}`; +} + +function PosterCard({ ev }: { ev: ShowcaseItem }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const statusLabel = + ev.status === 'ongoing' ? t('home.showcase.statusOngoing') : t('home.showcase.statusUpcoming'); + return ( + + ); +} + +/* ──────────────────────────────────────────────────────────── + * ④-좌 월 캘린더 (전시장 세그먼트 필터 + spanning bar) + 진행 리스트 + * ──────────────────────────────────────────────────────────── */ +function catStatusClass(s: CalStatus): string { + return s; +} + +function HomeCalendar() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [cursor, setCursor] = useState(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1)); + const [center, setCenter] = useState('all'); + const [popupDay, setPopupDay] = useState(null); + + const eventsQuery = useQuery({ + queryKey: ['home-cal-events'], + queryFn: async (): Promise => { + const rows = await exhibitionApi.list(); + return rows.map((e: ExhibitionDto) => ({ + id: e.id, + name: e.name, + start: e.startDate, + end: e.endDate, + hallLabel: e.hallLabel ?? null, + status: statusFromDates(e.startDate, e.endDate), + })); + }, + }); + const centersQuery = useQuery({ + queryKey: ['home-hall-centers'], + queryFn: () => homeApi.hallCenters(), + }); + + const allEvents = eventsQuery.data ?? []; + const filtered = useMemo( + () => allEvents.filter((e) => matchesCenter(e.hallLabel, center)), + [allEvents, center], + ); + + const year = cursor.getFullYear(); + const month = cursor.getMonth(); + const weeks = useMemo( + () => buildMonthGrid(year, month, filtered, TODAY), + [year, month, filtered], + ); + + const monthTitle = t('home.calendar.yearMonth', { y: year, m: month + 1 }); + const popupEvents = popupDay ? eventsOnDay(filtered, popupDay) : []; + + // 진행 중/예정 리스트 — 오늘 이후 종료, 시작일 오름차순, 최대 8건 + const listed = useMemo( + () => + [...filtered] + .filter((e) => e.status !== 'ended') + .sort((a, b) => (a.start < b.start ? -1 : 1)) + .slice(0, 8), + [filtered], + ); + + const centerOptions = [ + { code: 'all', label: t('home.calendar.all') }, + ...(centersQuery.data ?? []), + ]; + + return ( +
+
+
+ + {monthTitle} + + +
+ + {/* 전시장 세그먼트(동적 옵션) */} +
+ {centerOptions.map((c) => ( + + ))} +
+
+ + {/* 범례 */} + + + {eventsQuery.isLoading ? ( + + ) : eventsQuery.isError ? ( + eventsQuery.refetch()} /> + ) : ( + <> +
+
+ {[0, 1, 2, 3, 4, 5, 6].map((i) => ( + + {t(`common.weekday.${i}`)} + + ))} +
+ {weeks.map((wk, wi) => ( +
+ {/* 배경 일자 셀 */} +
+ {wk.cells.map((cell) => ( + + ))} +
+ {/* spanning bar 레인 */} + +
+ ))} +
+ + {/* 진행 중·예정 전시 리스트 */} +
+

{t('home.calendar.listTitle')}

+ {listed.length === 0 ? ( + navigate('/schedule')}>{t('home.calendar.listEmptyCta')}} + /> + ) : ( +
    + {listed.map((e) => ( +
  • + +
  • + ))} +
+ )} +
+ + )} + + {/* 일자 팝업(+N / 셀 클릭) */} + {popupDay && ( +
setPopupDay(null)} + > +
e.stopPropagation()} + > +
+ {popupDay} + +
+ {popupEvents.length === 0 ? ( +

{t('home.calendar.popupEmpty')}

+ ) : ( +
    + {popupEvents.map((e) => ( +
  • + +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ④-우① 내 할 일 + * ──────────────────────────────────────────────────────────── */ +function todoSeverityDot(sev: HomeTodoItem['severity']): string { + return sev === 'overdue' ? 'is-ended' : sev === 'warning' ? 'is-upcoming' : 'is-ongoing'; +} +function todoDueText(item: HomeTodoItem, t: (k: string, o?: Record) => string): string { + if (item.dday == null) return t('home.todo.noDue'); + if (item.dday < 0) return `D+${Math.abs(item.dday)}`; + return `D-${item.dday}`; +} + +function TodoWidget() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const query = useQuery({ queryKey: ['home-todos'], queryFn: () => homeApi.todos() }); + const data = query.data; + + return ( +
+
+

{t('home.todo.title')}

+ {data && data.total > 0 && {data.total}} +
+ + {query.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : query.isError ? ( + query.refetch()} /> + ) : !data || data.items.length === 0 ? ( +
+ +

{t('home.todo.emptyTitle')}

+

{t('home.todo.emptyDesc')}

+ +
+ ) : ( + <> +
    + {data.items.map((item, i) => ( +
  • + +
  • + ))} +
+ {data.total > data.items.length && ( + + )} + + )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ④-우② 공지사항 + * ──────────────────────────────────────────────────────────── */ +function isNew(createdAt: string): boolean { + const d = new Date(createdAt).getTime(); + return Number.isFinite(d) && Date.now() - d < 3 * 86400000; // 3일 이내 +} + +function NoticeWidget() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const query = useQuery({ + queryKey: ['home-notices'], + queryFn: () => noticeApi.list({ page: 0, size: 5 }), + }); + const items = query.data?.items ?? []; + + return ( +
+
+

{t('home.notice.title')}

+
+ {query.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : query.isError ? ( + query.refetch()} /> + ) : items.length === 0 ? ( + navigate('/work/notice')}>{t('home.notice.emptyCta')}} + /> + ) : ( + <> +
    + {items.map((n) => ( +
  • + +
  • + ))} +
+ + + )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ④-우③ 빠른 작업 (정적 라우트) + * ──────────────────────────────────────────────────────────── */ +function QuickActions() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const eventId = useResolvedEventId(); + + const actions: { key: string; Icon: typeof IconFloorplan; go: () => void }[] = [ + { key: 'editor', Icon: IconFloorplan, go: () => navigate(eventId ? `/events/${eventId}/halls/H7/layout` : '/schedule') }, + { key: 'invite', Icon: IconExhibitors, go: () => navigate(eventId ? `/events/${eventId}/dashboard` : '/schedule') }, + { key: 'docs', Icon: IconDocument, go: () => navigate('/docs') }, + { key: 'settlement', Icon: IconSettlement, go: () => navigate('/settlement') }, + { key: 'notice', Icon: IconBell, go: () => navigate('/work/notice') }, + { key: 'search', Icon: IconSearch, go: () => navigate('/work/search') }, + ]; + + return ( +
+
+

{t('home.quick.title')}

+
+
+ {actions.map((a) => ( + + ))} +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ④-우④ 최근 활동 (알림 피드 재사용) + * ──────────────────────────────────────────────────────────── */ +function relativeTime(iso: string, t: (k: string, o?: Record) => string): string { + const ts = new Date(iso).getTime(); + if (!Number.isFinite(ts)) return ''; + const diff = Math.max(0, Date.now() - ts); + const min = Math.floor(diff / 60000); + if (min < 1) return t('home.time.justNow'); + if (min < 60) return t('home.time.minutes', { n: min }); + const hr = Math.floor(min / 60); + if (hr < 24) return t('home.time.hours', { n: hr }); + const day = Math.floor(hr / 24); + return t('home.time.days', { n: day }); +} + +function ActivityWidget() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const query = useQuery({ + queryKey: ['home-activity'], + queryFn: () => notificationApi.list({ page: 0, size: 5 }), + }); + const items = query.data?.items ?? []; + + return ( +
+
+

{t('home.activity.title')}

+
+ {query.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : query.isError ? ( + query.refetch()} /> + ) : items.length === 0 ? ( + navigate('/notifications')}>{t('home.activity.emptyCta')}} + /> + ) : ( + <> +
    + {items.map((n) => ( +
  • + +
  • + ))} +
+ + + )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────── + * ⑤ 내 행사 워크스페이스 (authStore.workspaces) + * ──────────────────────────────────────────────────────────── */ +function WorkspaceGrid() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const workspaces = useAuthStore((s) => s.workspaces); + const selectEvent = useAuthStore((s) => s.selectEvent); + const [inviteCode, setInviteCode] = useState(''); + + function enter(eventId: string) { + selectEvent(eventId); + navigate(`/events/${eventId}/dashboard`); + } + + return ( +
+
+

{t('home.workspace.title')}

+
+ {workspaces.length === 0 ? ( +
+
+

{t('home.workspace.emptyTitle')}

+

{t('home.workspace.emptyDesc')}

+
+
{ + e.preventDefault(); + if (inviteCode.trim()) navigate('/login'); + }} + > + setInviteCode(e.target.value)} + placeholder={t('home.workspace.codePh')} + aria-label={t('home.workspace.codePh')} + /> + +
+
+ ) : ( +
+ {workspaces.map((ws) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/frontend/src/screens/home/home.css b/src/frontend/src/screens/home/home.css new file mode 100644 index 0000000..f87c3d6 --- /dev/null +++ b/src/frontend/src/screens/home/home.css @@ -0,0 +1,1026 @@ +@import '../shared.css'; + +/* ──────────────────────────────────────────────────────────── + * SCR-HOME 메인 홈 — 12컬럼 컴팩트, 대형 공백 금지(밀도 원칙). + * 라이트/다크 토큰 CSS 변수만 사용(하드코딩 hex 금지). 이모지 금지·선 SVG. + * ──────────────────────────────────────────────────────────── */ +.kx-home { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-5); /* 섹션 간 24px 표준(32px 상한) */ + background: var(--color-neutral-050); +} + +/* 공통 섹션 헤더 */ +.kx-home__section-head { + display: flex; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-3); +} +.kx-home__section-title { + font-size: var(--fs-h3); + font-weight: 700; + color: var(--color-neutral-900); + margin: 0; +} +.kx-home__section-link { + margin-left: auto; + border: none; + background: none; + color: var(--color-primary-600); + font-size: var(--fs-body); + cursor: pointer; + padding: 4px 6px; +} +.kx-home__section-link:hover { + text-decoration: underline; +} + +/* ── ① 인사 + KPI ── */ +.kx-home__hero-row { + display: flex; + align-items: center; + gap: var(--space-5); + flex-wrap: wrap; +} +.kx-home__greeting { + min-width: 220px; +} +.kx-home__hello { + font-size: var(--fs-h1); + font-weight: 700; + color: var(--color-neutral-900); + margin: 0 0 4px; +} +.kx-home__submeta { + display: flex; + align-items: center; + gap: var(--space-3); + color: var(--color-neutral-500); + font-size: var(--fs-body); + margin: 0; +} +.kx-home__kpis { + margin-left: auto; + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: var(--space-3); + flex: 1; + min-width: 0; +} +.kx-kpitile { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--space-3) var(--space-4); + background: var(--color-white); + border: var(--border-card); + border-left: 4px solid var(--color-neutral-200); + border-radius: var(--radius-lg); + text-align: left; + cursor: pointer; + min-height: 66px; +} +.kx-kpitile:hover { + box-shadow: var(--shadow-level2); +} +.kx-kpitile.is-normal { + border-left-color: var(--color-primary-600); +} +.kx-kpitile.is-warning { + border-left-color: var(--color-warning); +} +.kx-kpitile.is-overdue { + border-left-color: var(--color-error); +} +.kx-kpitile--error { + grid-column: 1 / -1; + cursor: default; +} +.kx-kpitile__label { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-kpitile__value { + font-size: var(--fs-h2); + font-weight: 700; + color: var(--color-neutral-900); +} +/* Stitch 정합: 임박/경과 타일 값 강조색 */ +.kx-kpitile.is-warning .kx-kpitile__value { + color: var(--color-warning); +} +.kx-kpitile.is-overdue .kx-kpitile__value { + color: var(--color-error); +} + +/* ── ② 배너 캐러셀 (220~260px) ── */ +.kx-home__banner { + position: relative; + height: 240px; + border-radius: var(--radius-lg); + overflow: hidden; + border: var(--border-card); + background: var(--color-neutral-100); +} +.kx-home__banner-slide { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + opacity: 0; + transition: opacity 0.6s ease; +} +.kx-home__banner-slide.is-active { + opacity: 1; +} +.kx-home__banner-scrim { + position: absolute; + inset: 0; + background: linear-gradient(90deg, rgba(16, 24, 40, 0.72) 0%, rgba(16, 24, 40, 0.24) 60%, transparent 100%); +} +.kx-home__banner-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 var(--space-8); + background: linear-gradient(120deg, var(--color-primary-600), var(--color-ai-accent)); +} +.kx-home__banner-text h2 { + color: #fff; + font-size: var(--fs-h1); + margin: 0 0 8px; +} +.kx-home__banner-text p { + color: rgba(255, 255, 255, 0.88); + font-size: var(--fs-body); + margin: 0; +} +.kx-home__banner-body { + position: absolute; + left: var(--space-8); + bottom: var(--space-6); + right: 30%; + color: #fff; +} +.kx-home__banner-title { + font-size: var(--fs-h1); + font-weight: 700; + margin: 0 0 6px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} +.kx-home__banner-sub { + font-size: var(--fs-body); + margin: 0; + color: rgba(255, 255, 255, 0.9); +} +.kx-home__banner-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.85); + color: var(--color-neutral-900); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.kx-home__banner-nav:hover { + background: #fff; +} +.kx-home__banner-nav.is-prev { + left: 12px; +} +.kx-home__banner-nav.is-next { + right: 12px; +} +.kx-home__banner-dots { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; +} +/* Stitch 정합: 대시(bar)형 인디케이터 */ +.kx-home__banner-dot { + width: 22px; + height: 3px; + border-radius: var(--radius-pill); + border: none; + background: rgba(255, 255, 255, 0.45); + cursor: pointer; + padding: 0; +} +.kx-home__banner-dot.is-active { + background: #fff; +} + +/* ── ③ 쇼케이스 포스터 레일 ── */ +.kx-home__rail-wrap { + position: relative; +} +.kx-home__rail { + display: flex; + gap: var(--space-3); + overflow-x: auto; + scroll-behavior: smooth; + padding-bottom: 4px; + scrollbar-width: thin; +} +.kx-home__rail-arrow { + position: absolute; + top: 120px; + width: 36px; + height: 36px; + border-radius: 50%; + border: var(--border-card); + background: var(--color-white); + color: var(--color-neutral-700); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: var(--shadow-level2); + z-index: 2; +} +.kx-home__rail-arrow.is-prev { + left: -8px; +} +.kx-home__rail-arrow.is-next { + right: -8px; +} +.kx-poster { + flex: 0 0 220px; + width: 220px; + display: flex; + flex-direction: column; + padding: 0; + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + overflow: hidden; + text-align: left; + cursor: pointer; + transition: box-shadow 0.15s ease; +} +.kx-poster:hover { + box-shadow: var(--shadow-level2); +} +.kx-poster__img { + display: block; + width: 100%; + height: 264px; /* Stitch 정합: 포스터 세로 비율(약 2:3) 고정 높이 */ + background-size: cover; + background-position: center; + background-color: var(--color-neutral-100); +} +.kx-poster__img--fallback { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + background: linear-gradient(140deg, var(--color-primary-600), var(--color-ai-accent)); +} +.kx-poster__fallback-text { + color: #fff; + font-size: var(--fs-h3); + font-weight: 700; + text-align: center; + line-height: 1.35; +} +.kx-poster__body { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px 12px; + border-left: 4px solid var(--color-neutral-200); /* Stitch 정합: 상태색 좌측 액센트 */ +} +.kx-poster__body.is-ongoing { + border-left-color: var(--color-success); +} +.kx-poster__body.is-upcoming { + border-left-color: var(--color-primary-600); +} +.kx-poster__body.is-ended { + border-left-color: var(--color-neutral-500); +} +.kx-poster__hall { + font-size: 10px; + font-weight: 700; + color: var(--color-primary-600); + margin-bottom: 1px; +} +.kx-poster__body.is-ongoing .kx-poster__hall { + color: var(--color-success); +} +.kx-poster__body.is-ended .kx-poster__hall { + color: var(--color-neutral-500); +} +.kx-poster__name { + font-size: var(--fs-body); + font-weight: 600; + color: var(--color-neutral-900); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.kx-poster__meta { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-poster__badges { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +/* 상태 칩(진행/예정) */ +.kx-home__chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-pill); + font-size: var(--fs-caption); + font-weight: 600; +} +.kx-home__chip.is-ongoing { + background: var(--color-success-bg); + color: var(--color-success); +} +.kx-home__chip.is-upcoming { + background: var(--color-primary-050); + color: var(--color-primary-600); +} +.kx-home__chip.is-ended { + background: var(--color-neutral-100); + color: var(--color-neutral-500); +} + +/* ── ④ 본문 2열 그리드 ── */ +.kx-home__body { + display: grid; + grid-template-columns: 8fr 4fr; + gap: var(--space-5); + align-items: start; +} +.kx-home__col-main, +.kx-home__col-side { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +/* 캘린더 */ +.kx-home__cal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + margin-bottom: var(--space-3); +} +.kx-home__cal-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); +} +.kx-home__cal-title { + font-size: var(--fs-h3); + font-weight: 700; + min-width: 120px; + text-align: center; +} +.kx-home__cal-nav, +.kx-home__cal-today { + border: var(--border-card); + background: var(--color-white); + border-radius: var(--radius-sm); + padding: 4px 8px; + cursor: pointer; + color: var(--color-neutral-700); + display: inline-flex; + align-items: center; +} +.kx-home__cal-today { + font-size: var(--fs-caption); +} +.kx-home__cal-seg { + flex-wrap: wrap; +} +.kx-home__cal-legend { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-2); + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-home__legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} +.kx-home__legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} +.kx-home__legend-dot.is-ongoing { + background: var(--color-success); +} +.kx-home__legend-dot.is-upcoming { + background: var(--color-primary-600); +} +.kx-home__legend-dot.is-ended { + background: var(--color-neutral-500); +} + +.kx-home__cal-grid { + border: var(--border-card); + border-radius: var(--radius-lg); + overflow: hidden; +} +.kx-home__cal-dow { + display: grid; + grid-template-columns: repeat(7, 1fr); + background: var(--color-neutral-050); + border-bottom: var(--border-card); +} +.kx-home__cal-wd { + padding: 6px 0; + text-align: center; + font-size: var(--fs-caption); + font-weight: 600; + color: var(--color-neutral-500); +} +.kx-home__cal-wd.is-sun { + color: var(--color-error); +} +.kx-home__cal-wd.is-sat { + color: var(--color-primary-600); +} +.kx-home__cal-week { + position: relative; + border-bottom: var(--border-card); +} +.kx-home__cal-week:last-child { + border-bottom: none; +} +.kx-home__cal-cells { + display: grid; + grid-template-columns: repeat(7, 1fr); +} +.kx-home__cal-cell { + min-height: 84px; + border-right: var(--border-card); + padding: 4px 6px; + text-align: left; + background: var(--color-white); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: flex-start; +} +.kx-home__cal-cell:last-child { + border-right: none; +} +.kx-home__cal-cell:hover { + background: var(--color-neutral-050); +} +.kx-home__cal-cell.is-out { + background: var(--color-neutral-050); +} +.kx-home__cal-cell.is-out .kx-home__cal-daynum { + color: var(--color-neutral-200); +} +.kx-home__cal-daynum { + font-size: var(--fs-caption); + color: var(--color-neutral-700); +} +.kx-home__cal-cell.is-sun .kx-home__cal-daynum { + color: var(--color-error); +} +.kx-home__cal-cell.is-sat .kx-home__cal-daynum { + color: var(--color-primary-600); +} +.kx-home__cal-cell.is-today .kx-home__cal-daynum { + background: var(--color-primary-600); + color: #fff; + border-radius: var(--radius-pill); + padding: 1px 7px; + font-weight: 700; +} +.kx-home__cal-more { + margin-top: auto; + font-size: 10px; + color: var(--color-neutral-500); +} +/* spanning bar 레인 — 셀 위에 절대배치(요일 헤더 아래 24px 오프셋) */ +.kx-home__cal-lanes { + position: absolute; + top: 22px; + left: 0; + right: 0; + pointer-events: none; +} +.kx-home__cal-bar { + position: absolute; + height: 18px; + line-height: 18px; + border-radius: 3px; + font-size: 11px; + padding: 0 6px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.kx-home__cal-bar.is-ongoing { + background: var(--color-success); +} +.kx-home__cal-bar.is-upcoming { + background: var(--color-primary-600); +} +.kx-home__cal-bar.is-ended { + background: var(--color-neutral-500); +} +.kx-home__cal-bar.is-cont-l { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.kx-home__cal-bar.is-cont-r { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.kx-home__cal-list { + margin-top: var(--space-4); +} +.kx-home__cal-list-title { + font-size: var(--fs-body); + font-weight: 700; + color: var(--color-neutral-900); + margin: 0 0 var(--space-2); +} +.kx-home__cal-list-items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); /* Stitch 정합: 카드형 행(간격 있는 박스) */ +} +.kx-home__cal-list-row { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 10px 12px; + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-neutral-050); + cursor: pointer; + text-align: left; +} +.kx-home__cal-list-row:hover { + background: var(--color-white); + border-color: var(--color-primary-600); +} +.kx-home__cal-list-name { + font-size: var(--fs-body); + color: var(--color-neutral-900); + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-home__cal-list-hall { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-home__cal-list-period { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} + +/* 일자 팝업 */ +.kx-home__cal-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(16, 24, 40, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 40; +} +.kx-home__cal-popup { + background: var(--color-white); + border-radius: var(--radius-lg); + border: var(--border-card); + width: 320px; + max-width: 90vw; + max-height: 70vh; + overflow-y: auto; + box-shadow: var(--shadow-level2); +} +.kx-home__cal-popup-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: var(--border-card); +} +.kx-home__cal-popup-close { + border: none; + background: none; + cursor: pointer; + color: var(--color-neutral-500); + font-size: var(--fs-body); +} +.kx-home__cal-popup-list { + list-style: none; + margin: 0; + padding: var(--space-2); +} +.kx-home__cal-popup-list button { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 8px; + border: none; + background: none; + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); + color: var(--color-neutral-900); + font-size: var(--fs-body); +} +.kx-home__cal-popup-list button:hover { + background: var(--color-neutral-050); +} +.kx-home__cal-popup-empty { + padding: var(--space-5); + text-align: center; + color: var(--color-neutral-500); + font-size: var(--fs-body); +} + +/* ── ④-우 위젯 공통 ── */ +.kx-home__widget { + display: flex; + flex-direction: column; +} +.kx-home__widget-head { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} +.kx-home__widget-head h2 { + font-size: var(--fs-h3); + font-weight: 700; + color: var(--color-neutral-900); + margin: 0; +} +.kx-home__count-badge { + background: var(--color-primary-600); + color: #fff; + border-radius: var(--radius-pill); + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: var(--fs-caption); + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} +.kx-home__widget-body { + display: flex; + flex-direction: column; + gap: 8px; +} +.kx-home__widget-more { + margin-top: var(--space-2); + align-self: flex-start; + border: none; + background: none; + color: var(--color-primary-600); + font-size: var(--fs-body); + cursor: pointer; + padding: 4px 0; +} +.kx-home__widget-more:hover { + text-decoration: underline; +} + +/* 내 할 일 */ +.kx-home__todo-list { + list-style: none; + margin: 0; + padding: 0; +} +.kx-home__todo-row { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 8px 4px; + border: none; + background: none; + border-bottom: var(--border-card); + cursor: pointer; + text-align: left; +} +.kx-home__todo-row:hover { + background: var(--color-neutral-050); +} +.kx-home__todo-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.kx-home__todo-title { + font-size: var(--fs-body); + color: var(--color-neutral-900); + font-weight: 500; +} +.kx-home__todo-target { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-home__todo-due { + font-size: var(--fs-caption); + font-weight: 700; + flex-shrink: 0; +} +.kx-home__todo-due.is-normal { + color: var(--color-neutral-500); +} +.kx-home__todo-due.is-warning { + color: var(--color-warning); +} +.kx-home__todo-due.is-overdue { + color: var(--color-error); +} +.kx-home__todo-done { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: var(--space-5) var(--space-4); + text-align: center; +} +.kx-home__todo-done-icon { + color: var(--color-success); +} +.kx-home__todo-done-title { + font-weight: 700; + color: var(--color-neutral-900); + margin: 0; +} +.kx-home__todo-done-desc { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + margin: 0 0 6px; +} + +/* 공지 */ +.kx-home__notice-list, +.kx-home__activity-list { + list-style: none; + margin: 0; + padding: 0; +} +.kx-home__notice-row { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 8px 4px; + border: none; + background: none; + border-bottom: var(--border-card); + cursor: pointer; + text-align: left; +} +.kx-home__notice-row:hover { + background: var(--color-neutral-050); +} +.kx-home__pin { + color: var(--color-primary-600); + font-size: 8px; +} +.kx-home__notice-title { + flex: 1; + min-width: 0; + font-size: var(--fs-body); + color: var(--color-neutral-900); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-home__new { + background: var(--color-error); + color: #fff; + font-size: 10px; + font-weight: 700; + border-radius: var(--radius-sm); + padding: 1px 5px; +} +.kx-home__notice-date { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + flex-shrink: 0; +} + +/* 빠른 작업 */ +.kx-home__quick-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); +} +.kx-home__quick-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: var(--space-3) 6px; + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + cursor: pointer; + color: var(--color-neutral-700); +} +.kx-home__quick-btn:hover { + border-color: var(--color-primary-600); + color: var(--color-primary-600); +} +.kx-home__quick-icon { + color: inherit; +} +.kx-home__quick-label { + font-size: var(--fs-caption); + text-align: center; + color: var(--color-neutral-700); +} + +/* 최근 활동 */ +.kx-home__activity-row { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 7px 4px; + border: none; + background: none; + border-bottom: var(--border-card); + cursor: pointer; + text-align: left; +} +.kx-home__activity-row:hover { + background: var(--color-neutral-050); +} +.kx-home__activity-text { + flex: 1; + min-width: 0; + font-size: var(--fs-body); + color: var(--color-neutral-900); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-home__activity-time { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + flex-shrink: 0; +} + +/* ── ⑤ 워크스페이스 ── */ +.kx-home__ws-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--space-4); +} +.kx-home__ws-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--space-4); + border: var(--border-card); + border-radius: var(--radius-lg); + background: var(--color-white); + cursor: pointer; + text-align: left; +} +.kx-home__ws-card:hover { + box-shadow: var(--shadow-level2); + border-color: var(--color-primary-600); +} +.kx-home__ws-card-top { + display: flex; + align-items: center; + justify-content: space-between; +} +.kx-home__ws-card-title { + font-size: var(--fs-h3); + font-weight: 700; + color: var(--color-neutral-900); + margin: 4px 0 0; +} +.kx-home__ws-card-meta, +.kx-home__ws-card-hall { + font-size: var(--fs-caption); + color: var(--color-neutral-500); + margin: 0; +} +.kx-home__ws-empty { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-5); + flex-wrap: wrap; +} +.kx-home__ws-empty-title { + font-weight: 700; + color: var(--color-neutral-900); + margin: 0 0 4px; +} +.kx-home__ws-empty-desc { + font-size: var(--fs-body); + color: var(--color-neutral-500); + margin: 0; +} +.kx-home__ws-invite { + display: flex; + gap: var(--space-2); + align-items: center; +} +.kx-home__ws-code { + height: 40px; + padding: 0 var(--space-3); + border: var(--border-card); + border-radius: var(--radius-sm); + background: var(--color-white); + color: var(--color-neutral-900); + font-size: var(--fs-body); + min-width: 180px; +} +.kx-home__ws-code:focus { + outline: 2px solid var(--color-primary-600); + outline-offset: 1px; +} + +/* prefers-reduced-motion — 배너 전환 즉시 */ +@media (prefers-reduced-motion: reduce) { + .kx-home__banner-slide { + transition: none; + } + .kx-home__rail { + scroll-behavior: auto; + } +} + +/* ── 반응형 (1100 / 720 브레이크) ── */ +@media (max-width: 1100px) { + .kx-home__body { + grid-template-columns: 1fr; + } + .kx-home__kpis { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 720px) { + .kx-home__hero-row { + flex-direction: column; + align-items: stretch; + } + .kx-home__kpis { + margin-left: 0; + } + .kx-home__quick-grid { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/src/frontend/src/screens/home/homeApi.ts b/src/frontend/src/screens/home/homeApi.ts new file mode 100644 index 0000000..8045d79 --- /dev/null +++ b/src/frontend/src/screens/home/homeApi.ts @@ -0,0 +1,72 @@ +/* + * SCR-HOME 메인 홈 로컬 API 클라이언트 — 백엔드 com.zioinfo.kintex.home 4개 집계 엔드포인트. + * 공용 client(../../api/client)의 `api` 만 import — client.ts·endpoints.ts·types.ts 무수정. + * 정본: _workspace/impl_home_api.md (응답 shape 그대로 소비). + * + * 각 엔드포인트는 프론트에서 독립 호출한다(영역별 스켈레톤/에러 — 한 영역 실패가 홈 전체를 막지 않음). + * 인증 필수(전 엔드포인트 anyRequest().authenticated()). PII·자격정보 미포함(서버 보장). + */ +import { api } from '../../api/client'; + +// ── 1) GET /api/home/todos — 내 할 일(역할 기반) ── +export type TodoType = 'approval' | 'document' | 'auction' | 'settlement' | 'system'; +export type TodoSeverity = 'normal' | 'warning' | 'overdue'; + +export interface HomeTodoItem { + type: TodoType; + title: string; + target: string; + dueDate: string | null; // "YYYY-MM-DD" | null + dday: number | null; // 잔여 일수(음수=경과, null=기한없음) + severity: TodoSeverity; + route: string; // 프론트 딥링크 +} +export interface HomeTodos { + total: number; // 병합된 실제 미결 건수 + items: HomeTodoItem[]; // 상위 6건 +} + +// ── 2) GET /api/home/showcase?tab=ongoing|thisMonth|nextMonth ── +export type ShowcaseTab = 'ongoing' | 'thisMonth' | 'nextMonth'; +export type ShowcaseStatus = 'ongoing' | 'upcoming' | 'ended'; + +export interface ShowcaseItem { + id: string; + name: string; + startDate: string; // "YYYY-MM-DD" + endDate: string; + hallLabel: string | null; + posterUrl: string | null; // null 이면 프론트 그라디언트+행사명 폴백 + status: ShowcaseStatus; + dday: number; // 개장까지 잔여(진행 중 = 0 이하) +} + +// ── 3) GET /api/home/hall-centers — 전시장(센터) 동적 목록 ── +export interface HallCenter { + code: string; // exhibition_center 정수 문자열 + label: string; // "제{code}전시장" +} + +// ── 4) GET /api/home/summary — 역할별 KPI 4타일 ── +export type KpiSeverity = 'normal' | 'warning' | 'overdue'; +export interface KpiTile { + key: string; + label: string; + value: number | string | null; // 집계 수치 또는 "D-n" 문자열, 미연동은 null → "—" + unit: string | null; + dday: number | null; + severity: KpiSeverity; + route: string; +} +export interface HomeSummary { + role: string; // ORGANIZER | EXHIBITOR | CONTRACTOR | ADMIN + tiles: KpiTile[]; +} + +export const homeApi = { + todos: () => api.get('/api/home/todos'), + showcase: (tab: ShowcaseTab = 'ongoing') => + api.get(`/api/home/showcase?tab=${encodeURIComponent(tab)}`), + hallCenters: () => api.get('/api/home/hall-centers'), + summary: () => api.get('/api/home/summary'), +}; diff --git a/src/frontend/src/screens/home/homeCalendar.ts b/src/frontend/src/screens/home/homeCalendar.ts new file mode 100644 index 0000000..8b954e1 --- /dev/null +++ b/src/frontend/src/screens/home/homeCalendar.ts @@ -0,0 +1,159 @@ +/* + * SCR-HOME 월 캘린더 레이아웃 — WISE(UIWS) CalendarView 패턴 참조(외부 라이브러리 미사용). + * 기간(2일+) 전시를 주 단위로 분할해 가로 spanning bar 로 배치(레인 그리디 할당), + * MAX_LANES 초과분은 일자별 "+N" 카운트로 축약한다. 단일일 전시도 span=1 bar 로 통일. + */ + +export type CalStatus = 'ongoing' | 'upcoming' | 'ended'; + +export interface CalEvent { + id: string; + name: string; + start: string; // "YYYY-MM-DD" + end: string; // "YYYY-MM-DD" + hallLabel: string | null; + status: CalStatus; +} + +export interface WeekBar { + event: CalEvent; + lane: number; + colStart: number; // 0..6 + span: number; // 1..7 + continuesLeft: boolean; // 이전 주에서 이어짐 + continuesRight: boolean; // 다음 주로 이어짐 +} + +export interface DayCell { + date: Date; + day: number; // 1..31 + inMonth: boolean; + isToday: boolean; + dow: number; // 0(일)..6(토) + key: string; // "YYYY-MM-DD" +} + +export interface WeekRow { + cells: DayCell[]; // 7 + bars: WeekBar[]; // lane < MAX_LANES 만 + hiddenByCol: number[]; // 7 — 각 열에서 숨겨진(레인 초과) 이벤트 수 + laneCount: number; // 표시 레인 수(0..MAX_LANES) — 행 높이 산정용 +} + +export const MAX_LANES = 3; + +/** "YYYY-MM-DD" → 로컬 Date(자정). 타임존 밀림 방지(UTC 파싱 회피). */ +function parseYmd(s: string): Date { + const [y, m, d] = s.split('-').map(Number); + return new Date(y, (m || 1) - 1, d || 1); +} +function ymd(d: Date): string { + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} +function addDays(d: Date, n: number): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +} +/** 자정 기준 일수 차(b - a). */ +function dayDiff(a: Date, b: Date): number { + const ms = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() - + new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime(); + return Math.round(ms / 86400000); +} + +/** + * 지정 월의 6주 × 7열 그리드 + 주별 spanning bar 를 계산한다. + * @param today 오늘(강조) — 미지정 시 강조 없음. + */ +export function buildMonthGrid( + year: number, + month: number, // 0-based + events: CalEvent[], + today?: Date, +): WeekRow[] { + const firstOfMonth = new Date(year, month, 1); + const gridStart = addDays(firstOfMonth, -firstOfMonth.getDay()); // 그 주 일요일로 + const todayKey = today ? ymd(today) : null; + + const weeks: WeekRow[] = []; + for (let w = 0; w < 6; w++) { + const weekStart = addDays(gridStart, w * 7); + const weekEnd = addDays(weekStart, 6); + + const cells: DayCell[] = []; + for (let c = 0; c < 7; c++) { + const date = addDays(weekStart, c); + cells.push({ + date, + day: date.getDate(), + inMonth: date.getMonth() === month, + isToday: todayKey != null && ymd(date) === todayKey, + dow: c, + key: ymd(date), + }); + } + + // 이 주와 겹치는 이벤트 → 주 내부 세그먼트로 클립 + type Seg = { event: CalEvent; colStart: number; colEnd: number; s: Date; e: Date }; + const segs: Seg[] = []; + for (const ev of events) { + const s = parseYmd(ev.start); + const e = parseYmd(ev.end); + if (e < weekStart || s > weekEnd) continue; + const colStart = Math.max(0, dayDiff(weekStart, s)); + const colEnd = Math.min(6, dayDiff(weekStart, e)); + if (colEnd < colStart) continue; + segs.push({ event: ev, colStart, colEnd, s, e }); + } + // 시작 열 오름차순, 긴 세그먼트 우선(레인 안정) + segs.sort((a, b) => a.colStart - b.colStart || b.colEnd - b.colStart - (a.colEnd - a.colStart)); + + const laneEnds: number[] = []; // 레인별 마지막 점유 열 + const bars: WeekBar[] = []; + const hiddenByCol = [0, 0, 0, 0, 0, 0, 0]; + + for (const seg of segs) { + // 첫 빈 레인 탐색 + let lane = laneEnds.findIndex((end) => end < seg.colStart); + if (lane === -1) { + lane = laneEnds.length; + laneEnds.push(seg.colEnd); + } else { + laneEnds[lane] = seg.colEnd; + } + if (lane < MAX_LANES) { + bars.push({ + event: seg.event, + lane, + colStart: seg.colStart, + span: seg.colEnd - seg.colStart + 1, + continuesLeft: seg.s < weekStart, + continuesRight: seg.e > weekEnd, + }); + } else { + for (let c = seg.colStart; c <= seg.colEnd; c++) hiddenByCol[c] += 1; + } + } + + weeks.push({ + cells, + bars, + hiddenByCol, + laneCount: Math.min(laneEnds.length, MAX_LANES), + }); + } + return weeks; +} + +/** 특정 일자에 걸치는 전 이벤트(팝업용) — 정렬: 시작일→이름. */ +export function eventsOnDay(events: CalEvent[], dayKey: string): CalEvent[] { + const d = parseYmd(dayKey); + return events + .filter((ev) => { + const s = parseYmd(ev.start); + const e = parseYmd(ev.end); + return s <= d && d <= e; + }) + .sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.name < b.name ? -1 : 1)); +} diff --git a/src/frontend/src/screens/login/LoginPage.tsx b/src/frontend/src/screens/login/LoginPage.tsx index 1fb6b9a..53f9c86 100644 --- a/src/frontend/src/screens/login/LoginPage.tsx +++ b/src/frontend/src/screens/login/LoginPage.tsx @@ -5,18 +5,18 @@ import { authApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import { useAuthStore } from '../../store/authStore'; import { Button } from '../../components/ui/Button'; -import { DdayChip, RoleBadge } from '../../components/ui/Badge'; -import { EmptyState } from '../../components/ui/States'; import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher'; import { InviteDialog } from './InviteDialog'; import { SignupDialog } from './SignupDialog'; import { ForgotPasswordDialog } from './ForgotPasswordDialog'; import { LoginSlides } from './LoginSlides'; import { OtpChallengePanel } from './OtpChallengePanel'; -import type { LoginResponse, WorkspaceDto } from '../../api/types'; +import type { LoginResponse } from '../../api/types'; import './login.css'; -type Phase = 'login' | 'otp' | 'workspace'; +// v2.1.2 — 로그인 성공 시 메인 홈(SCR-HOME)으로 랜딩한다. 기존 '워크스페이스 선택' phase 는 +// 제거하고 워크스페이스 선택은 SCR-HOME '내 행사' 섹션(WorkspaceGrid)으로 흡수했다. +type Phase = 'login' | 'otp'; type OtpMode = 'verify' | 'enroll'; // 아이디 기억 — 이메일만 저장(비번 저장 금지). @@ -26,8 +26,6 @@ export function LoginPage() { const { t } = useTranslation(); const navigate = useNavigate(); const applyLogin = useAuthStore((s) => s.applyLogin); - const selectEvent = useAuthStore((s) => s.selectEvent); - const workspaces = useAuthStore((s) => s.workspaces); const [phase, setPhase] = useState('login'); const [email, setEmail] = useState(''); @@ -50,13 +48,14 @@ export function LoginPage() { } }, []); - // 최종 로그인 확정(공통) — 토큰 적용 + 워크스페이스 단계 이동. + // 최종 로그인 확정(공통) — 토큰 적용 후 메인 홈(SCR-HOME)으로 랜딩(v2.1.2). + // 회원가입 직후 흐름도 로그인 성공 경로를 공유하므로 동일하게 /home 으로 랜딩한다. function completeLogin(res: LoginResponse) { if (remember) localStorage.setItem(REMEMBER_KEY, email.trim()); else localStorage.removeItem(REMEMBER_KEY); applyLogin(res); setOtpChallenge(null); - setPhase('workspace'); + navigate('/home'); } async function handleLogin(e: React.FormEvent) { @@ -96,12 +95,6 @@ export function LoginPage() { } } - function enterWorkspace(ws: WorkspaceDto) { - selectEvent(ws.eventId); - // 주최자는 배치 에디터 진입점 예시(SCR-03). 홀 id는 워크스페이스에 홀 단일 가정 — 실제 홀 선택은 대시보드에서. - navigate(`/events/${ws.eventId}/halls/H7/layout`); - } - if (phase === 'otp' && otpChallenge) { return (
@@ -124,57 +117,6 @@ export function LoginPage() { ); } - if (phase === 'workspace') { - return ( -
-
-
-

{t('login.wsTitle')}

-

{t('login.wsSub')}

-
- - {workspaces.length === 0 ? ( - setInviteOpen(true)}>{t('login.wsInvite')}} - /> - ) : ( - <> -
- {workspaces.map((ws) => ( - - ))} -
-
- -
- - )} -
- - {inviteOpen && setInviteOpen(false)} />} -
- ); - } - return (
{/* 좌측 55% 브랜드 패널 — 관리자 등록 슬라이드 캐러셀(페이드·5초·도트) */} diff --git a/src/frontend/src/screens/movein/DockReservationPage.tsx b/src/frontend/src/screens/movein/DockReservationPage.tsx index 841559a..459afcd 100644 --- a/src/frontend/src/screens/movein/DockReservationPage.tsx +++ b/src/frontend/src/screens/movein/DockReservationPage.tsx @@ -8,14 +8,59 @@ */ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { AiLabel } from '../../components/ui/Badge'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { IconSpark } from '../../components/ui/icons'; import { ApiRequestError } from '../../api/client'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { logisticsApi, type Dock, type GridReservation } from './logisticsApi'; +import { EquipmentTab, InboundTab, RentalTab } from './LogisticsExtTabs'; import './movein.css'; +type LogisticsTab = 'dock' | 'equipment' | 'rental' | 'inbound'; + +/** + * SCR-25 물류 허브(/logistics) — M8. 탭 4종: 도크 예약(기존) · 장비 신청(F041) · 렌탈 주문(F042) · 선입고(F043). + * 도크 예약 탭은 기존 스케줄 그리드를 그대로 유지, 확장 탭은 LogisticsExtTabs 로 위임. + */ +export function DockReservationPage() { + const { t } = useTranslation(); + const eventId = useResolvedEventId(); + const [tab, setTab] = useState('dock'); + const tabs: LogisticsTab[] = ['dock', 'equipment', 'rental', 'inbound']; + + return ( +
+
+
+

{t('logistics.title')}

+

{t('logistics.subtitle')}

+
+
+ +
+ {tabs.map((key) => ( + + ))} +
+ + {tab === 'dock' && } + {tab === 'equipment' && } + {tab === 'rental' && } + {tab === 'inbound' && } +
+ ); +} + const WEIGHTS = ['1t', '2.5t', '5t', '11t 이상']; interface DockForm { @@ -31,7 +76,7 @@ function hourLabels(gridStartHour: number, hours: number): string[] { return Array.from({ length: hours }, (_, i) => `${String(gridStartHour + i).padStart(2, '0')}:00`); } -export function DockReservationPage() { +function DockScheduleTab() { const eventId = useResolvedEventId(); const queryClient = useQueryClient(); const [direction, setDirection] = useState<'in' | 'out'>('in'); @@ -116,14 +161,7 @@ export function DockReservationPage() { : 'success'; return ( -
-
-
-

반입/반출 슬롯 예약

-

하역장·화물출입구 도크 예약 · 통행증 발급

-
-
- + <> {/* 필터 바 */}
)} -
+ ); } diff --git a/src/frontend/src/screens/movein/LogisticsExtTabs.tsx b/src/frontend/src/screens/movein/LogisticsExtTabs.tsx new file mode 100644 index 0000000..8c71209 --- /dev/null +++ b/src/frontend/src/screens/movein/LogisticsExtTabs.tsx @@ -0,0 +1,484 @@ +/* + * M8 물류 확장 탭 — F041 장비 신청 · F042 렌탈 주문 · F043 선입고 트래킹. + * DockReservationPage(/logistics) 탭 확장으로 진입. 기존 도크 예약 컨벤션(카드·필터·토스트) 재사용. + * 실 API: logisticsExtApi(GET /logistics/ext · POST 신청/주문/등록 · POST 상태전이). 금액은 서버 권위 표시만. + */ +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { ApiRequestError } from '../../api/client'; +import { + logisticsExtApi, + type EquipmentItem, + type EquipmentRequestView, + type InboundView, + type RentalItem, + type RentalOrderView, +} from './logisticsExtApi'; + +function useBoard(eventId: string | null) { + return useQuery({ + queryKey: ['logi-ext', eventId], + queryFn: () => logisticsExtApi.board(eventId as string), + enabled: !!eventId, + retry: false, + }); +} + +function krw(n: number): string { + return `${(n ?? 0).toLocaleString('ko-KR')}원`; +} + +function useToast(): [string | null, (m: string | null) => void] { + const [toast, setToast] = useState(null); + useEffect(() => { + if (!toast) return; + const t = setTimeout(() => setToast(null), 2600); + return () => clearTimeout(t); + }, [toast]); + return [toast, setToast]; +} + +/** 상태 배지 — 상태코드별 톤 매핑. */ +function StatusPill({ ns, status }: { ns: 'equipment' | 'rental' | 'inbound'; status: string }) { + const { t } = useTranslation(); + const tone = + status === 'approved' || status === 'confirmed' || status === 'done' || status === 'delivered' || status === 'delivered_booth' + ? 'ok' + : status === 'rejected' || status === 'cancelled' + ? 'no' + : 'wait'; + return {t(`logistics.${ns}.st.${status}`)}; +} + +/* ════════════════════════ 장비 신청 (F041) ════════════════════════ */ +export function EquipmentTab({ eventId }: { eventId: string | null }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const boardQ = useBoard(eventId); + const [toast, setToast] = useToast(); + const [equipmentId, setEquipmentId] = useState(''); + const [boothNo, setBoothNo] = useState(''); + const [quantity, setQuantity] = useState(1); + const [useDate, setUseDate] = useState(''); + const [days, setDays] = useState(1); + + const board = boardQ.data; + const catalog: EquipmentItem[] = board?.equipmentCatalog ?? []; + const rows: EquipmentRequestView[] = board?.equipmentRequests ?? []; + const canManage = board?.canManage ?? false; + const selected = catalog.find((c) => c.id === equipmentId) ?? catalog[0]; + const estimate = selected ? selected.unitPrice * Math.max(1, quantity) * Math.max(1, days) : 0; + + const invalidate = () => qc.invalidateQueries({ queryKey: ['logi-ext', eventId] }); + + const submit = useMutation({ + mutationFn: () => + logisticsExtApi.requestEquipment(eventId as string, { + equipmentId: selected?.id ?? '', + boothNo: boothNo || undefined, + quantity: Math.max(1, quantity), + useDate: useDate || undefined, + days: Math.max(1, days), + }), + onSuccess: () => { + invalidate(); + setToast(t('logistics.submitOk')); + }, + onError: (e) => setToast(e instanceof ApiRequestError ? e.message : t('logistics.submitErr')), + }); + + const decide = useMutation({ + mutationFn: (v: { id: string; status: string; reason?: string }) => + logisticsExtApi.decideEquipment(eventId as string, v.id, { status: v.status, reason: v.reason }), + onSuccess: () => { + invalidate(); + setToast(t('logistics.statusUpdated')); + }, + onError: (e) => setToast(e instanceof ApiRequestError ? e.message : t('logistics.submitErr')), + }); + + if (boardQ.isPending) return ; + if (boardQ.isError) return boardQ.refetch()} />; + + return ( +
+
+ {t('logistics.equipment.formTitle')} +
+ + +
+ + +
+ +

+ {t('logistics.estimate')} {krw(estimate)} +

+ +
+
+ +
+ {t('logistics.equipment.listTitle')} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((r) => ( +
  • +
    + {r.equipmentName} + +
    +
    + {r.boothNo ? `${r.boothNo} · ` : ''} + {t('logistics.qty')} {r.quantity} · {r.days}{t('logistics.equipment.daysUnit')} · {krw(r.amount)} + {r.useDate ? ` · ${r.useDate}` : ''} +
    + {r.rejectReason ?
    {r.rejectReason}
    : null} + {canManage && r.status === 'requested' && ( +
    + + +
    + )} + {canManage && r.status === 'approved' && ( +
    + +
    + )} +
  • + ))} +
+ )} +
+ {toast &&
{toast}
} +
+ ); +} + +/* ════════════════════════ 렌탈 주문 (F042) ════════════════════════ */ +export function RentalTab({ eventId }: { eventId: string | null }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const boardQ = useBoard(eventId); + const [toast, setToast] = useToast(); + const [itemId, setItemId] = useState(''); + const [boothNo, setBoothNo] = useState(''); + const [quantity, setQuantity] = useState(1); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const board = boardQ.data; + const catalog: RentalItem[] = board?.rentalCatalog ?? []; + const rows: RentalOrderView[] = board?.rentalOrders ?? []; + const canManage = board?.canManage ?? false; + const selected = catalog.find((c) => c.id === itemId) ?? catalog[0]; + + const days = useMemo(() => { + if (startDate && endDate) { + const s = new Date(startDate).getTime(); + const e = new Date(endDate).getTime(); + if (!Number.isNaN(s) && !Number.isNaN(e) && e >= s) { + return Math.floor((e - s) / 86400000) + 1; + } + } + return 1; + }, [startDate, endDate]); + const estimate = selected ? selected.dailyPrice * Math.max(1, quantity) * days : 0; + + const invalidate = () => qc.invalidateQueries({ queryKey: ['logi-ext', eventId] }); + + const submit = useMutation({ + mutationFn: () => + logisticsExtApi.orderRental(eventId as string, { + itemId: selected?.id ?? '', + boothNo: boothNo || undefined, + quantity: Math.max(1, quantity), + startDate: startDate || undefined, + endDate: endDate || undefined, + days, + }), + onSuccess: () => { + invalidate(); + setToast(t('logistics.submitOk')); + }, + onError: (e) => setToast(e instanceof ApiRequestError ? e.message : t('logistics.submitErr')), + }); + + const decide = useMutation({ + mutationFn: (v: { id: string; status: string }) => logisticsExtApi.decideRental(eventId as string, v.id, { status: v.status }), + onSuccess: () => { + invalidate(); + setToast(t('logistics.statusUpdated')); + }, + onError: (e) => setToast(e instanceof ApiRequestError ? e.message : t('logistics.submitErr')), + }); + + if (boardQ.isPending) return ; + if (boardQ.isError) return boardQ.refetch()} />; + + return ( +
+
+ {t('logistics.rental.formTitle')} +
+ + + +
+ + +
+

+ {t('logistics.rental.period', { days })} · {t('logistics.estimate')} {krw(estimate)} +

+ +
+
+ +
+ {t('logistics.rental.listTitle')} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((r) => ( +
  • +
    + {r.itemName} + +
    +
    + {r.boothNo ? `${r.boothNo} · ` : ''} + {t('logistics.qty')} {r.quantity} · {t('logistics.rental.period', { days: r.days })} · {krw(r.amount)} + {r.startDate ? ` · ${r.startDate}~${r.endDate ?? ''}` : ''} +
    + {canManage && r.status === 'ordered' && ( +
    + + +
    + )} + {canManage && r.status === 'confirmed' && ( +
    + +
    + )} +
  • + ))} +
+ )} +
+ {toast &&
{toast}
} +
+ ); +} + +/* ════════════════════════ 선입고 트래킹 (F043) ════════════════════════ */ +const INBOUND_FLOW = ['registered', 'inbound_scheduled', 'arrived_warehouse', 'delivered_booth'] as const; +const NEXT_INBOUND: Record = { + registered: 'inbound_scheduled', + inbound_scheduled: 'arrived_warehouse', + arrived_warehouse: 'delivered_booth', + delivered_booth: null, +}; + +export function InboundTab({ eventId }: { eventId: string | null }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const boardQ = useBoard(eventId); + const [toast, setToast] = useToast(); + const [trackingNo, setTrackingNo] = useState(''); + const [carrier, setCarrier] = useState(''); + const [boothNo, setBoothNo] = useState(''); + const [itemDesc, setItemDesc] = useState(''); + const [quantity, setQuantity] = useState(1); + const [etaDate, setEtaDate] = useState(''); + + const board = boardQ.data; + const rows: InboundView[] = board?.inbound ?? []; + const canManage = board?.canManage ?? false; + + const invalidate = () => qc.invalidateQueries({ queryKey: ['logi-ext', eventId] }); + + const submit = useMutation({ + mutationFn: () => + logisticsExtApi.registerInbound(eventId as string, { + trackingNo: trackingNo.trim(), + carrier: carrier || undefined, + boothNo: boothNo || undefined, + itemDesc: itemDesc || undefined, + quantity: Math.max(1, quantity), + etaDate: etaDate || undefined, + }), + onSuccess: () => { + invalidate(); + setTrackingNo(''); + setToast(t('logistics.submitOk')); + }, + onError: (e) => setToast(e instanceof ApiRequestError ? e.message : t('logistics.submitErr')), + }); + + const advance = useMutation({ + mutationFn: (v: { id: string; status: string }) => logisticsExtApi.advanceInbound(eventId as string, v.id, { status: v.status }), + onSuccess: () => { + invalidate(); + setToast(t('logistics.statusUpdated')); + }, + onError: (e) => setToast(e instanceof ApiRequestError ? e.message : t('logistics.submitErr')), + }); + + if (boardQ.isPending) return ; + if (boardQ.isError) return boardQ.refetch()} />; + + return ( +
+
+ {t('logistics.inbound.formTitle')} +
+ + +
+ + +
+ + + +
+
+ +
+ {t('logistics.inbound.listTitle')} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((r) => { + const step = INBOUND_FLOW.indexOf(r.status as (typeof INBOUND_FLOW)[number]); + const next = NEXT_INBOUND[r.status]; + return ( +
  • +
    + {r.trackingNo} + +
    +
    + {r.carrier ? `${r.carrier} · ` : ''} + {r.boothNo ? `${r.boothNo} · ` : ''} + {r.itemDesc ?? ''} {t('logistics.qty')} {r.quantity} + {r.etaDate ? ` · ${t('logistics.inbound.eta')} ${r.etaDate}` : ''} +
    + + {canManage && next && ( +
    + +
    + )} +
  • + ); + })} +
+ )} +
+ {toast &&
{toast}
} +
+ ); +} diff --git a/src/frontend/src/screens/movein/logisticsExtApi.ts b/src/frontend/src/screens/movein/logisticsExtApi.ts new file mode 100644 index 0000000..622fa11 --- /dev/null +++ b/src/frontend/src/screens/movein/logisticsExtApi.ts @@ -0,0 +1,148 @@ +/* + * M8 물류 확장(F041 장비 신청 · F042 렌탈 주문 · F043 선입고 트래킹) 실배선 API 계약. + * 공용 클라이언트(../../api/client)만 사용 — client.ts·endpoints.ts·types.ts 무수정. + * 정본: 백엔드 LogisticsExtController 시그니처 (/api/events/{eventId}/logistics/*) + * - GET /ext → ApiResponse + * - POST /equipment-requests → 장비 신청(멤버, 금액 서버 계산) + * - POST /equipment-requests/{id}/status → 승인/반려/처리완료(매니저) + * - POST /rental-orders → 렌탈 주문(멤버, 기간·금액 서버 산출) + * - POST /rental-orders/{id}/status → 확정/취소/납품(매니저) + * - POST /inbound → 운송장 등록(멤버) + * - POST /inbound/{id}/status → 입고예정/창고도착/부스전달 전이(매니저) + */ +import { api } from '../../api/client'; + +export interface EquipmentItem { + id: string; + code: string; + name: string; + category: string; + unit: string; + unitPrice: number; + spec: string | null; +} +export interface EquipmentRequestView { + id: string; + equipmentId: string; + equipmentName: string; + category: string | null; + unitPrice: number; + boothNo: string | null; + quantity: number; + useDate: string | null; + days: number; + amount: number; + status: 'requested' | 'approved' | 'rejected' | 'done'; + note: string | null; + rejectReason: string | null; + createdAt: string; +} +export interface RentalItem { + id: string; + code: string; + name: string; + category: string; + unit: string; + dailyPrice: number; + spec: string | null; +} +export interface RentalOrderView { + id: string; + itemId: string; + itemName: string; + category: string | null; + dailyPrice: number; + boothNo: string | null; + quantity: number; + startDate: string | null; + endDate: string | null; + days: number; + amount: number; + status: 'ordered' | 'confirmed' | 'cancelled' | 'delivered'; + note: string | null; + createdAt: string; +} +export interface InboundView { + id: string; + trackingNo: string; + carrier: string | null; + boothNo: string | null; + itemDesc: string | null; + quantity: number; + status: 'registered' | 'inbound_scheduled' | 'arrived_warehouse' | 'delivered_booth'; + etaDate: string | null; + warehouseAt: string | null; + deliveredAt: string | null; + note: string | null; + createdAt: string; +} +export interface LogisticsExtBoard { + canManage: boolean; + equipmentCatalog: EquipmentItem[]; + equipmentRequests: EquipmentRequestView[]; + rentalCatalog: RentalItem[]; + rentalOrders: RentalOrderView[]; + inbound: InboundView[]; +} + +export interface EquipmentRequestBody { + equipmentId: string; + boothNo?: string; + quantity: number; + useDate?: string; + days: number; + note?: string; +} +export interface RentalOrderBody { + itemId: string; + boothNo?: string; + quantity: number; + startDate?: string; + endDate?: string; + days: number; + note?: string; +} +export interface InboundBody { + trackingNo: string; + carrier?: string; + boothNo?: string; + itemDesc?: string; + quantity: number; + etaDate?: string; + note?: string; +} +export interface StatusDecisionBody { + status: string; + reason?: string; +} +export interface InboundStatusBody { + status: string; + note?: string; +} + +const base = (eventId: string) => `/api/events/${encodeURIComponent(eventId)}/logistics`; + +export const logisticsExtApi = { + board: (eventId: string) => api.get(`${base(eventId)}/ext`), + + requestEquipment: (eventId: string, body: EquipmentRequestBody) => + api.post(`${base(eventId)}/equipment-requests`, body), + decideEquipment: (eventId: string, id: string, body: StatusDecisionBody) => + api.post( + `${base(eventId)}/equipment-requests/${encodeURIComponent(id)}/status`, + body, + ), + + orderRental: (eventId: string, body: RentalOrderBody) => + api.post(`${base(eventId)}/rental-orders`, body), + decideRental: (eventId: string, id: string, body: StatusDecisionBody) => + api.post( + `${base(eventId)}/rental-orders/${encodeURIComponent(id)}/status`, + body, + ), + + registerInbound: (eventId: string, body: InboundBody) => + api.post(`${base(eventId)}/inbound`, body), + advanceInbound: (eventId: string, id: string, body: InboundStatusBody) => + api.post(`${base(eventId)}/inbound/${encodeURIComponent(id)}/status`, body), +}; diff --git a/src/frontend/src/screens/movein/movein.css b/src/frontend/src/screens/movein/movein.css index c3a9e0a..528bde6 100644 --- a/src/frontend/src/screens/movein/movein.css +++ b/src/frontend/src/screens/movein/movein.css @@ -371,3 +371,129 @@ font-size: var(--fs-body); box-shadow: 0 6px 20px rgba(16, 24, 40, 0.24); } + +/* ── M8 물류 확장 탭(장비 신청·렌탈 주문·선입고) ───────────────────────── */ +.kx-logi__tabs { + margin-bottom: 16px; + flex-wrap: wrap; +} +.kx-logi__split { + display: grid; + grid-template-columns: minmax(280px, 360px) 1fr; + gap: 16px; + align-items: start; +} +@media (max-width: 860px) { + .kx-logi__split { + grid-template-columns: 1fr; + } +} +.kx-logi__row2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.kx-logi__estimate { + margin: 4px 0 2px; + font-size: var(--fs-body); + color: var(--color-text-secondary, #475467); +} +.kx-logi__estimate strong { + color: var(--color-text, #101828); +} +.kx-logi__list { + min-height: 220px; +} +.kx-logi__items { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.kx-logi__item { + border: 1px solid var(--color-border, #e4e7ec); + border-radius: var(--radius-md, 10px); + padding: 12px 14px; + background: var(--color-surface, #fff); +} +.kx-logi__item-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.kx-logi__item-name { + font-weight: 600; + color: var(--color-text, #101828); +} +.kx-logi__item-meta { + margin-top: 4px; + font-size: var(--fs-caption, 13px); + color: var(--color-text-secondary, #475467); +} +.kx-logi__reason { + margin-top: 4px; + font-size: var(--fs-caption, 13px); + color: #b42318; +} +.kx-logi__actions { + margin-top: 10px; + display: flex; + gap: 8px; +} +.kx-logi__act { + border: 1px solid var(--color-border, #e4e7ec); + background: var(--color-surface, #fff); + border-radius: var(--radius-sm, 8px); + padding: 6px 12px; + font-size: var(--fs-caption, 13px); + cursor: pointer; +} +.kx-logi__act:disabled { + opacity: 0.5; + cursor: default; +} +.kx-logi__act--ok { + border-color: #12b76a; + color: #027a48; +} +.kx-logi__act--no { + border-color: #f04438; + color: #b42318; +} +.kx-logi__pill { + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 999px; + white-space: nowrap; +} +.kx-logi__pill--ok { + background: #ecfdf3; + color: #027a48; +} +.kx-logi__pill--no { + background: #fef3f2; + color: #b42318; +} +.kx-logi__pill--wait { + background: #fffaeb; + color: #b54708; +} +.kx-logi__track { + margin-top: 8px; + display: flex; + gap: 6px; + align-items: center; +} +.kx-logi__track-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--color-border, #e4e7ec); +} +.kx-logi__track-dot.is-done { + background: var(--color-primary, #1f29fc); +} diff --git a/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx b/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx index 39a55d4..9e44976 100644 --- a/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx +++ b/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx @@ -28,6 +28,7 @@ import { type ScheduleEvent, } from './sampleSchedule'; import { scheduleApi } from './scheduleApi'; +import { hallAssignApi } from '../hallassign/hallAssignApi'; import './schedule.css'; /** 집계 API 실패 시 크롤링 샘플로 강등할지 판정(NETWORK/NOT_FOUND/NOT_IMPLEMENTED). */ @@ -256,6 +257,7 @@ export function ExhibitionSchedulePage() {
@@ -403,6 +405,81 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date) ); } +/** + * F001 가용성 보강 — 홀 가용(빈 기간) 표시. hallassign API(홀 마스터) + 기존 홀 가동률 집계를 재사용해 + * 지정 월의 홀 그룹별 "가용 일수(총일-점유일)"와 가용 여부 배지를 보여준다. 집계 실패 시 홀 마스터만으로 + * "가용 미확인"으로 강등한다(외부 라이브러리·신규 백엔드 없음). + */ +function HallAvailability({ cursor }: { cursor: Date }) { + const { t } = useTranslation(); + const year = cursor.getFullYear(); + const month = cursor.getMonth(); + + const query = useQuery<{ + rows: Array<{ label: string; freeDays: number; totalDays: number; available: boolean }>; + hallCount: number; + degraded: boolean; + }>({ + queryKey: ['schedule-hall-avail', year, month], + queryFn: async () => { + const halls = await hallAssignApi.halls().catch(() => [] as Awaited>); + try { + const util = await scheduleApi.hallUtilization(year, month + 1); + const rows = util.map((h) => { + const totalDays = h.totalDays || 0; + const freeDays = Math.max(0, totalDays - (h.busyDays || 0)); + return { label: h.label, freeDays, totalDays, available: totalDays === 0 || freeDays > 0 }; + }); + return { rows, hallCount: halls.length, degraded: false }; + } catch (err) { + if (isDegradable(err)) { + const rows = hallUtilizationFor(year, month).map((h) => ({ + label: h.label, + freeDays: 0, + totalDays: 0, + available: h.pct < 100, + })); + return { rows, hallCount: halls.length, degraded: true }; + } + throw err; + } + }, + }); + + const data = query.data; + + return ( +
+
+

{t('schedule.availMonth', { y: year, m: month + 1 })}

+ {data?.degraded && {t('schedule.offlineSample')}} +
+ {query.isLoading ? ( + + ) : ( + <> + {data && data.hallCount > 0 && ( +

{t('schedule.availHalls', { n: data.hallCount })}

+ )} +
    + {(data?.rows ?? []).map((r) => ( +
  • + {r.label} + + {r.totalDays > 0 ? t('schedule.availFreeDays', { free: r.freeDays, total: r.totalDays }) : ''} + + + {r.available ? t('schedule.availOpen') : t('schedule.availFull')} + +
  • + ))} +
+ + )} +
+ ); +} + function HallUtilization({ cursor }: { cursor: Date }) { const { t } = useTranslation(); const year = cursor.getFullYear(); diff --git a/src/frontend/src/screens/schedule/schedule.css b/src/frontend/src/screens/schedule/schedule.css index b3be269..d95189f 100644 --- a/src/frontend/src/screens/schedule/schedule.css +++ b/src/frontend/src/screens/schedule/schedule.css @@ -334,3 +334,30 @@ height: 120px; } } + +/* F001 홀 가용성(빈 기간) 목록 — 스케줄 aside 보강 */ +.kx-availlist { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.kx-availlist__item { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 8px; + font-size: 12px; +} +.kx-availlist__label { color: var(--kx-text, #0f172a); } +.kx-availlist__meta { color: var(--kx-text-muted, #64748b); } +.kx-availbadge { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; +} +.kx-availbadge.is-open { color: #15803d; background: #f0fdf4; } +.kx-availbadge.is-full { color: #b45309; background: #fffbeb; } diff --git a/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx b/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx index 5016adc..a7f4670 100644 --- a/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx +++ b/src/frontend/src/screens/settlement/SettlementDashboardPage.tsx @@ -9,7 +9,7 @@ import { CHART } from '../chartColors'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useAuthStore } from '../../store/authStore'; import { settlementApi } from './settlementApi'; -import type { InvoiceStatus, StatusBucket } from './settlementApi'; +import type { InvoiceCategory, InvoiceStatus, ReportGroup, StatusBucket } from './settlementApi'; import './settlement.css'; /* @@ -27,9 +27,10 @@ const STATUS_META: Record = { invoiced: { color: CHART.primary600, tone: 'info' }, paid: { color: CHART.success, tone: 'ok' }, overdue: { color: CHART.error, tone: 'error' }, + cancelled: { color: CHART.slate, tone: 'muted' }, }; -const STATUS_FILTER_VALUES: Array<'' | InvoiceStatus> = ['', 'pending', 'invoiced', 'paid', 'overdue']; +const STATUS_FILTER_VALUES: Array<'' | InvoiceStatus> = ['', 'pending', 'invoiced', 'paid', 'overdue', 'cancelled']; /** 청구 상태 → 현지화 라벨. '' 은 전체 필터. */ function statusLabel(t: TFunction, status: '' | InvoiceStatus): string { @@ -198,7 +199,10 @@ export function SettlementDashboardPage() { {list.items.map((inv) => ( setSelectedId(inv.id)}> {inv.invoiceNo} - {inv.exhibitorName} + + {inv.exhibitorName} + + {formatWon(inv.total)} {formatWon(inv.paidAmount)} {formatWon(inv.outstanding)} @@ -245,6 +249,9 @@ export function SettlementDashboardPage() { )} + {/* 정산 리포트(카테고리별 합계·수납율·미수 + CSV) */} + + {selectedId && ( settlementApi.report(eventId), + enabled: open && !!eventId, + retry: false, + }); + + const rep = reportQ.data; + + const downloadCsv = () => { + if (!rep) return; + const header = [ + t('settlement.colNo'), + t('settlement.colExhibitor'), + t('settlement.colCategory'), + t('settlement.colStatus'), + t('settlement.supplyAmount'), + t('settlement.vat'), + t('settlement.colBilled'), + t('settlement.colPaid'), + t('settlement.colOutstanding'), + t('settlement.colDue'), + ]; + const lines = rep.rows.map((r) => + [ + r.invoiceNo, + r.exhibitorName, + r.category, + r.status, + r.subtotal, + r.vat, + r.total, + r.paidAmount, + r.outstanding, + r.dueDate ?? '', + ] + .map(csvCell) + .join(','), + ); + const csv = '' + [header.map(csvCell).join(','), ...lines].join('\r\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `settlement_report_${eventId}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

{t('settlement.reportTitle')}

+
+ + {open && rep && ( + + )} +
+
+ + {!open ? ( +

{t('settlement.reportHint')}

+ ) : reportQ.isLoading ? ( + + ) : reportQ.isError || !rep ? ( + reportQ.refetch()} /> + ) : ( +
+
+ + {t('settlement.colBilled')}: {formatWon(rep.totalBilled)} + + + {t('settlement.colPaid')}: {formatWon(rep.totalCollected)} + + + {t('settlement.colOutstanding')}: {formatWon(rep.totalOutstanding)} + + + {t('settlement.collectionRate', { rate: rep.collectionRate.toFixed(1) })} + +
+ + +

{t('settlement.reportNote')}

+
+ )} +
+ ); +} + +function ReportGroupTable({ title, groups, category }: { title: string; groups: ReportGroup[]; category?: boolean }) { + const { t } = useTranslation(); + return ( +
+

{title}

+ {groups.length === 0 ? ( +

{t('settlement.reportEmpty')}

+ ) : ( + + + + + + + + + + + + + {groups.map((g) => ( + + + + + + + + + ))} + +
{category ? t('settlement.colCategory') : t('settlement.colStatus')}{t('settlement.reportCount')}{t('settlement.colBilled')}{t('settlement.colPaid')}{t('settlement.colOutstanding')}{t('settlement.reportRate')}
{groupLabel(t, g.key, category)}{g.invoiceCount.toLocaleString()}{g.billed.toLocaleString()}{g.collected.toLocaleString()}{g.outstanding.toLocaleString()}{g.collectionRate.toFixed(1)}%
+ )} +
+ ); +} + +function groupLabel(t: TFunction, key: string, category?: boolean): string { + if (category) { + return key === 'auction_fee' ? t('settlement.catAuctionFee') : t('settlement.catRental'); + } + const known: InvoiceStatus[] = ['pending', 'invoiced', 'paid', 'overdue', 'cancelled']; + return (known as string[]).includes(key) ? t(`settlement.status.${key}`) : key; +} + +/** CSV 셀 이스케이프(쉼표·따옴표·줄바꿈 방어). */ +function csvCell(v: string | number): string { + const s = String(v ?? ''); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + // ── 헤더 ───────────────────────────────────────────────────────────────────── function PageHeader({ canManage, @@ -361,6 +526,13 @@ function StatusDistribution({ buckets }: { buckets: StatusBucket[] }) { ); } +// ── 유형 표시(F084 옥션 수수료 구분) ───────────────────────────────────────── +function CategoryTag({ category }: { category: InvoiceCategory }) { + const { t } = useTranslation(); + const label = category === 'auction_fee' ? t('settlement.catAuctionFee') : t('settlement.catRental'); + return {label}; +} + // ── 상태 표시 ───────────────────────────────────────────────────────────────── function StatusPill({ status }: { status: InvoiceStatus }) { const { t } = useTranslation(); @@ -413,7 +585,37 @@ function InvoiceDetailModal({ onSuccess: invalidate, }); + // F083 세금계산서 + const taxQ = useQuery({ + queryKey: ['settlement', 'tax', invoiceId], + queryFn: () => settlementApi.getTaxInvoice(invoiceId), + retry: false, + }); + const issueTaxM = useMutation({ + mutationFn: () => settlementApi.issueTaxInvoice(invoiceId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['settlement', 'tax', invoiceId] }); + }, + }); + + // F085 환불·취소 + const [showCancel, setShowCancel] = useState(false); + const [cancelReason, setCancelReason] = useState(''); const d = detailQ.data; + const refundQ = useQuery({ + queryKey: ['settlement', 'refund', invoiceId], + queryFn: () => settlementApi.refundQuote(invoiceId), + enabled: showCancel && !!d && d.status !== 'cancelled', + retry: false, + }); + const cancelM = useMutation({ + mutationFn: () => settlementApi.cancel(invoiceId, { reason: cancelReason || undefined }), + onSuccess: () => { + setShowCancel(false); + setCancelReason(''); + invalidate(); + }, + }); const submitPayment = () => { const n = Number(amount); @@ -454,6 +656,10 @@ function InvoiceDetailModal({ {t('settlement.colExhibitor')} {d.exhibitorName}
+
+ {t('settlement.colCategory')} + +
{t('settlement.colStatus')} @@ -535,15 +741,67 @@ function InvoiceDetailModal({ )}
+ {/* 세금계산서(F083) */} +
+

{t('settlement.taxTitle')}

+ {taxQ.isLoading ? ( + + ) : taxQ.data ? ( +
+
+ {t('settlement.taxNo')} + {taxQ.data.taxInvoiceNo} +
+
+ {t('settlement.taxSupplier')} + {taxQ.data.supplierName} +
+
+ {t('settlement.taxBuyer')} + {taxQ.data.buyerName} +
+
+ {t('settlement.taxItem')} + {taxQ.data.itemSummary} +
+
+ + {t('settlement.supplyAmount')}: {taxQ.data.supplyAmount.toLocaleString()} + + + {t('settlement.vat')}: {taxQ.data.vat.toLocaleString()} + + + {t('settlement.grandTotal')}: {taxQ.data.total.toLocaleString()} + +
+

{taxQ.data.issueNote}

+
+ ) : ( +

{t('settlement.taxNone')}

+ )} + {d.canManage && !taxQ.data && d.status !== 'pending' && d.status !== 'cancelled' && ( + + )} + {issueTaxM.isError && ( +

{t('settlement.taxIssueFail')}

+ )} +
+ {/* 매니저 액션 */} {d.canManage ? (
+ {d.status === 'cancelled' && ( +

{t('settlement.cancelledNote')}

+ )} {d.status === 'pending' && ( )} - {d.outstanding > 0 && ( + {d.status !== 'cancelled' && d.outstanding > 0 && (
)} + + {/* 취소·환불(F085) */} + {d.status !== 'cancelled' && ( +
+ {!showCancel ? ( + + ) : ( +
+

{t('settlement.refundPreview')}

+ {refundQ.isLoading ? ( + + ) : refundQ.isError || !refundQ.data ? ( + refundQ.refetch()} /> + ) : ( +
+
+ {t('settlement.refundBracket')} + + {refundQ.data.bracketLabel} + {refundQ.data.daysBeforeEvent != null && + ` (D-${refundQ.data.daysBeforeEvent})`} + +
+
+ {t('settlement.refundRate')} + {refundQ.data.refundRatePercent}% +
+
+ {t('settlement.refundBase')} + {refundQ.data.baseAmount.toLocaleString()} +
+
+ {t('settlement.refundAmount')} + {refundQ.data.refundAmount.toLocaleString()} +
+
+ {t('settlement.forfeitAmount')} + {refundQ.data.forfeitAmount.toLocaleString()} +
+ {!refundQ.data.dateResolved && ( +

{t('settlement.refundManual')}

+ )} +

{refundQ.data.pgNote}

+
+ )} + setCancelReason(e.target.value)} + aria-label={t('settlement.cancelReason')} + /> +
+ + +
+ {cancelM.isError && ( +

{t('settlement.cancelFail')}

+ )} +
+ )} +
+ )}
) : (

{t('settlement.managerOnly')}

diff --git a/src/frontend/src/screens/settlement/settlement.css b/src/frontend/src/screens/settlement/settlement.css index 2c99008..b45dffb 100644 --- a/src/frontend/src/screens/settlement/settlement.css +++ b/src/frontend/src/screens/settlement/settlement.css @@ -335,3 +335,124 @@ color: var(--color-neutral-500); text-align: left; } + +/* F084 정산 유형 태그(임대 / 옥션 수수료 구분) */ +.kx-settle__cattag { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + vertical-align: middle; + white-space: nowrap; +} +.kx-settle__cattag--rental { + background: var(--color-neutral-100, #f2f4f7); + color: var(--color-neutral-600, #475467); +} +.kx-settle__cattag--auction_fee { + background: var(--color-primary-surface, #eef2ff); + color: var(--color-primary-600, #2f42fc); +} + +/* ── W9: F085 환불·취소 + F083 세금계산서 + 정산 리포트 ───────────────────────── */ +.kx-settle__tax { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--color-neutral-200, #e4e7ec); + display: grid; + gap: 8px; +} +.kx-settle__tax h3 { + margin: 0; + font-size: 13px; + font-weight: 600; +} +.kx-settle__tax-doc { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--color-neutral-200, #e4e7ec); + border-radius: 8px; + background: var(--color-neutral-50, #f9fafb); +} +.kx-settle__tax-row { + display: flex; + gap: 10px; + font-size: 12px; +} +.kx-settle__tax-amounts { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 4px; + font-size: 12px; +} +.kx-settle__cancel { + margin-top: 12px; +} +.kx-settle__cancel-box { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--color-error-border, #fda29b); + border-radius: 8px; + background: var(--color-error-surface, #fffbfa); +} +.kx-settle__cancel-box h4 { + margin: 0; + font-size: 13px; + font-weight: 600; +} +.kx-settle__refund { + display: grid; + gap: 4px; +} +.kx-settle__refund-row { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} +.kx-settle__refund-row--em { + padding-top: 4px; + border-top: 1px dashed var(--color-neutral-300, #d0d5dd); + font-size: 13px; +} +.kx-settle__refund-row--em strong { + color: var(--color-primary-600, #2f42fc); +} +.kx-settle__cancel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.kx-settle__report { + margin-top: 16px; +} +.kx-settle__report-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.kx-settle__report-actions { + display: flex; + gap: 8px; +} +.kx-settle__report-body { + display: grid; + gap: 16px; +} +.kx-settle__report-summary { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; +} +.kx-settle__report-group h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 600; +} diff --git a/src/frontend/src/screens/settlement/settlementApi.ts b/src/frontend/src/screens/settlement/settlementApi.ts index 66c45ed..8912ea4 100644 --- a/src/frontend/src/screens/settlement/settlementApi.ts +++ b/src/frontend/src/screens/settlement/settlementApi.ts @@ -7,7 +7,9 @@ */ import { api } from '../../api/client'; -export type InvoiceStatus = 'pending' | 'invoiced' | 'paid' | 'overdue'; +export type InvoiceStatus = 'pending' | 'invoiced' | 'paid' | 'overdue' | 'cancelled'; +/** 정산 유형(F084) — rental(임대·유틸) | auction_fee(옥션 수수료). */ +export type InvoiceCategory = 'rental' | 'auction_fee'; export interface StatusBucket { status: InvoiceStatus; @@ -41,6 +43,7 @@ export interface InvoiceSummary { status: InvoiceStatus; dueDate: string | null; issuedAt: string | null; + category: InvoiceCategory; } export interface LineItem { @@ -74,6 +77,7 @@ export interface InvoiceDetail { issuedAt: string | null; paidAt: string | null; note: string | null; + category: InvoiceCategory; lineItems: LineItem[]; payments: PaymentRecord[]; canManage: boolean; @@ -99,6 +103,87 @@ export interface RecordPaymentInput { memo?: string; } +// ── F085 환불·취소 ──────────────────────────────────────────────────────────── +/** 환불 산정 미리보기/확정 근거. 금액은 서버 계산 권위. base = 실 납입액 기준. */ +export interface RefundQuote { + invoiceId: string; + invoiceNo: string; + status: InvoiceStatus; + total: number; + baseAmount: number; + refundRatePercent: number; + refundAmount: number; + forfeitAmount: number; + daysBeforeEvent: number | null; + bracketLabel: string; + eventStartDate: string | null; + dateResolved: boolean; + rulesetVersion: string; + pgNote: string; +} + +export interface CancelResult { + invoice: InvoiceDetail; + refund: RefundQuote; +} + +// ── F083 세금계산서 ────────────────────────────────────────────────────────── +export interface TaxInvoice { + id: string; + invoiceId: string; + eventId: string; + taxInvoiceNo: string; + supplierName: string; + supplierBizNo: string | null; + supplierCeo: string | null; + supplierAddr: string | null; + buyerName: string; + supplyAmount: number; + vat: number; + total: number; + itemSummary: string | null; + status: string; // issued | void + issuedAt: string; + issueNote: string; +} + +// ── 정산 리포트 ────────────────────────────────────────────────────────────── +export interface ReportGroup { + key: string; + invoiceCount: number; + billed: number; + collected: number; + outstanding: number; + collectionRate: number; +} + +export interface ReportRow { + invoiceNo: string; + exhibitorName: string; + category: string; + status: string; + subtotal: number; + vat: number; + total: number; + paidAmount: number; + outstanding: number; + dueDate: string | null; +} + +export interface SettlementReport { + eventId: string; + eventName: string | null; + generatedAt: string; + totalBilled: number; + totalCollected: number; + totalOutstanding: number; + collectionRate: number; + invoiceCount: number; + byCategory: ReportGroup[]; + byStatus: ReportGroup[]; + rows: ReportRow[]; +} + export const settlementApi = { dashboard: (eventId: string) => api.get(`/api/settlement/dashboard?eventId=${encodeURIComponent(eventId)}`), @@ -119,4 +204,22 @@ export const settlementApi = { recordPayment: (id: string, input: RecordPaymentInput) => api.post(`/api/settlement/invoices/${encodeURIComponent(id)}/payments`, input), + + // F085 환불·취소 + refundQuote: (id: string, cancelDate?: string) => { + const q = cancelDate ? `?cancelDate=${encodeURIComponent(cancelDate)}` : ''; + return api.get(`/api/settlement/invoices/${encodeURIComponent(id)}/refund-quote${q}`); + }, + cancel: (id: string, body: { reason?: string; cancelDate?: string }) => + api.post(`/api/settlement/invoices/${encodeURIComponent(id)}/cancel`, body), + + // F083 세금계산서 (없으면 data=null) + getTaxInvoice: (id: string) => + api.get(`/api/settlement/invoices/${encodeURIComponent(id)}/tax-invoice`), + issueTaxInvoice: (id: string) => + api.post(`/api/settlement/invoices/${encodeURIComponent(id)}/tax-invoice`), + + // 정산 리포트 + report: (eventId: string) => + api.get(`/api/settlement/report?eventId=${encodeURIComponent(eventId)}`), }; diff --git a/src/frontend/src/screens/visitor/LeadScoringPage.tsx b/src/frontend/src/screens/visitor/LeadScoringPage.tsx index ac50c1c..e478c66 100644 --- a/src/frontend/src/screens/visitor/LeadScoringPage.tsx +++ b/src/frontend/src/screens/visitor/LeadScoringPage.tsx @@ -24,6 +24,8 @@ export function LeadScoringPage() { const [selectedId, setSelectedId] = useState(''); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); + const [rescoringId, setRescoringId] = useState(null); + const [rescoreNote, setRescoreNote] = useState(null); const handleExport = async () => { if (!eventId || exporting) return; @@ -45,6 +47,25 @@ export function LeadScoringPage() { retry: false, }); + const handleRescore = async (leadId: string) => { + if (!eventId || rescoringId) return; + setRescoringId(leadId); + setRescoreNote(null); + try { + const r = await visitorApi.rescoreLead(eventId, leadId); + setRescoreNote( + r.degraded + ? 'AI 미가용 — 기존 규칙 스코어 유지' + : `AI 재계산 완료 · ${r.provider}`, + ); + await q.refetch(); + } catch { + setRescoreNote('AI 재계산에 실패했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + setRescoringId(null); + } + }; + const degraded = isDegradable(q.error); const leads: LeadItemDto[] = q.data?.items ?? (degraded ? SAMPLE_LEADS : []); const total = q.data?.total ?? (degraded ? SAMPLE_LEADS.length : 0); @@ -192,7 +213,17 @@ export function LeadScoringPage() { {/* 리드 상세·팔로업 */} - {selected && } + {selected && ( + + )}
)} @@ -200,7 +231,23 @@ export function LeadScoringPage() { ); } -function LeadDetail({ lead, onExport, exporting }: { lead: LeadItemDto; onExport: () => void; exporting: boolean }) { +function LeadDetail({ + lead, + onExport, + exporting, + onRescore, + rescoring, + canRescore, + rescoreNote, +}: { + lead: LeadItemDto; + onExport: () => void; + exporting: boolean; + onRescore: (leadId: string) => void; + rescoring: boolean; + canRescore: boolean; + rescoreNote: string | null; +}) { const hot = lead.score >= 80; return (