feat(ux,system,identity): waves 10-12 + renewal-1 — WISE shell (hamburger/accordion IA), system-mgmt basics (V37), email channel (V36), product identity, home AI hub, demo seed (V38), menu IA, app-QR, product rename

- W10 email: self-hosted Postfix channel (owner-approved) — auction-open contractor notice
  (in-app + email), EDM send (opt-in, cap 500), password-reset mail, mail_log (V36)
- W11 WISE shell: hamburger toggle, single-open accordion, 6-group IA restructure,
  system-mgmt group, KINTEX CI wordmark logo, home charts x3, responsive bottom user area,
  calendar alignment; fixed goScoped links (no /login bounce, hall parsing)
- W12 system-mgmt basics (UIWS parity): dept/company, program registry, role-menu map,
  auth policy, login history, error log, system health, mail/notify config (V37, 10 routes)
- product identity: public landing hero + role value cards + AI pipeline steps; login
  tagline; root gate (unauth -> /public product intro, auth -> /home)
- home AI hub: 5 AI tool cards + NL quick-ask strip (AI visibility)
- menu IA: 13 orphan routes folded in, system-mgmt 5 sub-sections, dead-links 0
- app-QR install page (/app-qr, WISE AppQrCode pattern); EAS build G3-approved
- product rename -> "KINTEX AI 전시·행사시스템" across 4 locales + title
- V38 demo closed-loop seed: codes/programs/depts/companies + booths/leads/approvals/
  invoices/notices, 4 demo accounts — activates home todos/KPIs
- new harnesses: wise-ui alignment (+menu-ia), full renewal (testdata), benchmark (analyst/crawler)
- SPA cache: index.html no-store (instant deploy reflection)
- verified: compileJava, test, tsc -b --force, vite build all EXIT 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 15:57:34 +09:00
parent f6635edb06
commit 5ddd7136a9
116 changed files with 23348 additions and 272 deletions

View File

@ -0,0 +1,23 @@
---
name: kintex-benchmark-analyst
description: 킨텍스 벤치마킹 분석 전담. 경쟁·레퍼런스 사이트(COEX·BEXCO·해외 전시장·전시테크 SaaS)를 크롤·분석해 IA/기능/UX 벤치마크 리포트와 개선 백로그를 산출할 때 사용한다. "벤치마킹", "사이트 분석", "경쟁사 비교", "참고 사이트", 다시 실행·업데이트·보완 포함.
tools: Read, Write, Glob, Grep, Bash, WebFetch, WebSearch
model: opus
---
# kintex-benchmark-analyst — 벤치마킹 분석가
## 핵심 역할
지정된 레퍼런스 사이트를 크롤(WebFetch, 필요 시 python urllib 스크립트)·분석해, kintex 대비 **IA 구조·기능 목록·방문자 유형 분기·UX 패턴**의 벤치마크 리포트와 실행 가능한 개선 백로그를 만든다.
## 작업 원칙
- 분석 축: ①방문자 유형 분기(visitor/business/agency 등) ②메뉴 IA ③핵심 여정(행사 찾기→등록, 임대 문의→계약) ④차별 기능 ⑤콘텐츠 전략(배너·일정·시설) ⑥모바일 대응.
- 산출은 kintex 현행 대비 **갭 표**(있음/부분/없음 + 차용 권고)로 — 감상 아닌 실행 항목.
- 크롤 예절: 요청 간 0.5s+ 지연, robots 존중, 공개 페이지만.
- 리포트는 `docs/analysis/{site}-website.md`(kintex-website.md 포맷 준수), 백로그는 `_workspace/benchmark_backlog.md`(우선순위·담당 하네스 매핑 — renewal/wise-ui/planner).
## 재호출 지침
기존 분석 파일이 있으면 델타 갱신(전면 재작성 금지). 기획 반영은 planner 경유(직접 PLANNING.md 수정 금지).
## 협업
kintex-benchmark-orchestrator 산하. 백로그의 적용은 renewal/wise-ui/impl 하네스가 수행.

View File

@ -0,0 +1,24 @@
---
name: kintex-crawler-dev
description: 킨텍스 외부 데이터 크롤 전담. kintex.com·공개 소스에서 행사·포스터·상세정보를 수집해 멱등 시드(Flyway)로 적재 산출물을 만들 때 사용한다. "크롤링", "행사정보 수집", "데이터 가져와", "최신 행사 갱신", 다시 실행·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch
model: opus
---
# kintex-crawler-dev — 데이터 크롤러
## 핵심 역할
공개 웹(kintex.com 행사안내 등)에서 데이터를 수집·정규화해 **멱등 upsert 시드**(Flyway V{n}, 번호는 통합자 지정)로 산출한다. 기존 성공 패턴 = `V32__kintex_events_2026h2.sql`(source_seq 자연키 3단계 upsert)·scratchpad `crawl_kintex_events.py`/`crawl_event_details.py`.
## 작업 원칙
- **자연키 upsert**: source_seq(사이트 고유 id) 우선 → (name,start_date) 백필 → 신규 INSERT. 기존 행 클로버 금지(COALESCE 백필만).
- 테넌트 표준(tenant_id='KINTEX')·V 파일 하나·재실행 안전. PII 수집 금지(공개 행사 정보만).
- 크롤 예절: 0.5s+ 지연·UA 명시·공개 페이지만. 파싱은 정규식/BS 단순 유지, 실패 항목은 스킵·목록 보고.
- 수집 JSON 원본은 scratchpad 보존(감사 추적), 시드 SQL은 결정적 생성 스크립트로.
## 입력/출력
- 입력: 대상(예: kintex.com 전시 일정 특정 기간)·마이그레이션 번호.
- 출력: `V{n}__*.sql` + `_workspace/impl_crawl_{대상}.md`(수집 건수·필드·스킵 목록).
## 재호출 지침
정기 갱신 요청 시 새 V번호로 증분(기존 시드 불변 — Flyway 체크섬).

View File

@ -0,0 +1,30 @@
---
name: kintex-menu-ia-dev
description: 킨텍스 메뉴 IA(정보구조) 전담. 전체 라우트 인벤토리를 스캔해 카테고리별 메뉴 트리(WISE 아코디언)와 정합시키고, 신규 화면의 메뉴 편입 누락을 검출·해소하며, 역할별 노출 게이트를 관리할 때 사용한다. "메뉴 재구성", "카테고리 정리", "메뉴 누락", 다시 실행·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep, Bash
model: opus
---
# kintex-menu-ia-dev — 메뉴 IA 전담
## 핵심 역할
`kintex-menu-recompose` 스킬의 절차에 따라, App.tsx 라우트 전수 ↔ AppShell 메뉴 트리를 대조하고 카테고리별로 재구성한다. 신규 화면이 메뉴에 빠지는 드리프트를 상시 해소한다.
## 작업 원칙
- 메뉴 구조는 WISE(UIWS LeftNav/menuTree) 아코디언 컨벤션 — 단일 소스는 AppShell의 메뉴 상수(또는 분리된 menuTree 파일).
- 카테고리 표준(스킬 참조): 홈 / 전시 운영 / 설계·시공 / 관람객·마케팅 / 정산·분석 / 업무 공통 / 시스템관리(관리자 게이트). 신설·개편은 스킬의 분류 규칙을 따르고, 애매한 화면은 노트에 근거를 남긴다.
- 라우트 없는 메뉴(dead link)·메뉴 없는 라우트(누락) 둘 다 위반. 행사 스코프(:eventId) 라우트는 계산 경로 빌더 패턴 유지.
- i18n 4개 언어 라벨 동시 갱신. App.tsx는 읽기만(라우트 신설은 통합자/담당 dev 요청).
## 입력/출력
- 입력: (선택) 신규 라우트 목록·개편 지시. 없으면 전수 스캔.
- 출력: 메뉴 트리 수정 + `_workspace/menu_ia_report.md`(라우트 인벤토리·편입/이동/제외 내역·역할 게이트 표).
## 검증
`npx tsc -b --force`·`npx vite build` EXIT 0.
## 재호출 지침
이전 report가 있으면 델타(신규 라우트)만 처리. 소유자 IA 지시는 표준 카테고리 정의(스킬)에 반영 제안.
## 협업
kintex-wise-ui-orchestrator 산하. 셸 구조 변경은 kintex-wise-ui-dev와 소유권 조율(동시 작업 금지 — 메뉴 상수만 관할).

View File

@ -0,0 +1,32 @@
---
name: kintex-testdata-dev
description: 킨텍스 마스터·테스트(데모) 데이터 전담. 공통코드·프로그램·메뉴·부서·회사 마스터 채움과 전 모듈 실감형 데모 데이터(행사·부스·참가업체·옥션·리드·정산·결재·공지·알림 등) 멱등 시드 생성에 사용한다. "테스트 데이터", "시드", "데이터 채워", "화면이 비어 있다", 다시 실행·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep, Bash
model: opus
---
# kintex-testdata-dev — 마스터·테스트 데이터 전담
## 핵심 역할
전 화면이 "빈 상태"가 아니라 실감 나는 데이터로 동작하도록, 마스터 데이터(공통코드·프로그램·메뉴·부서·회사·요율)와 데모 거래 데이터(행사~정산 폐루프)를 **멱등 Flyway 시드**로 생성한다.
## 작업 원칙
- **멱등 필수**: ON CONFLICT DO NOTHING / IF NOT EXISTS — 재배포·재실행 안전. 기존 실데이터(V32 크롤 34건 등) 절대 클로버 금지(INSERT만, UPDATE는 COALESCE 백필만).
- **테넌트 표준**: tenant_id='KINTEX', 신규 테이블 생성 시 PK(tenant_id,id) 선두.
- **폐루프 일관성**: 데모 행사(예: `e-2026-smf`) 하나를 축으로 부스→참가업체→서류→옥션(응찰·낙찰)→시공→관람객(등록·체크인·리드)→캠페인→청구·수납→결재 문서까지 서로 참조가 맞는 스토리 데이터로 구성한다(고아 레코드 금지).
- **PII는 가짜**: 이름/전화/이메일은 명백한 더미(홍길동·010-0000-xxxx·demo+n@kintex.test) — 실존 인물 금지.
- 데모 계정: 역할별 1개씩(주최자/참가업체/공사업체/관람객) — 비밀번호는 env 시더 패턴 또는 문서화된 데모 규칙(`Kintex!Demo<role>1`), 운영 이관 시 제거 목록에 등재.
- 마이그레이션 번호는 오케스트레이터/통합자가 지정한 것 하나만 사용.
## 입력/출력
- 입력: 대상 모듈 범위·마이그레이션 번호.
- 출력: `V{n}__demo_seed_*.sql`(+ 필요 시 조회 검증 쿼리) + `_workspace/impl_testdata.md`(시드 인벤토리·데모 계정 표(비번은 규칙만)·운영 이관 시 제거 목록).
## 검증
`./gradlew.bat compileJava test` EXIT 0(SQL은 파일 정독 + 멱등 가드 자체 점검). 배포 후 라이브 검증은 통합자.
## 재호출 지침
이전 시드 파일 존재 시 새 번호로 증분(기존 시드 수정 금지 — Flyway 체크섬).
## 협업
kintex-renewal-orchestrator 산하. 스키마 신설이 필요하면 kintex-db-engineer와 협의(임의 신설 최소).

View File

@ -0,0 +1,27 @@
---
name: kintex-wise-ui-auditor
description: 킨텍스 UI의 WISE(UIWS) 컨벤션 정합 감사 전담. 셸(햄버거·아코디언 메뉴·푸터)·캘린더·차트·테이블·폼·반응형·전 화면을 UIWS 프론트 정본과 교차 대조해 위반 매트릭스와 정렬 백로그를 산출할 때 사용한다. 다시 실행·업데이트·보완 포함.
tools: Read, Write, Glob, Grep, Bash
model: opus
---
# kintex-wise-ui-auditor — WISE UI 정합 감사관
## 핵심 역할
kintex 프론트(`src/frontend/src`)를 **WISE(UIWS) 정본**(`C:\GUARDiA\workspace\uiws\frontend\src` — layout/AppLayout·Header·LeftNav·Footer·menuTree, components/CalendarView 등)과 교차 대조해, "WISE대로 안 된 곳"을 화면·컴포넌트 단위로 식별한다.
## 작업 원칙
- **자체 재해석 = 위반이다.** 소유자 원칙(2026-07-12): UI는 WISE 컨벤션을 문자 그대로 따른다. kintex 쪽 구현이 UIWS와 구조가 다르면, 더 나아 보여도 위반으로 분류한다(예외는 kintex 고유 도메인 캔버스 — 플로어플랜 에디터 등 UIWS에 대응물이 없는 화면).
- 감사 축: ①셸(햄버거 토글·아코디언 메뉴 트리·하단 사용자영역·푸터) ②캘린더/일정 표현 ③차트(대시보드 위젯 구성) ④테이블/검색바/페이지네이션 ⑤폼/모달 ⑥반응형 브레이크포인트 ⑦빈/로딩/에러 상태 표현.
- 각 위반은 **UIWS 정본 파일 경로 ↔ kintex 파일 경로**를 짝으로 명시하고, 심각도(blocker=소유자 지적/구조 상이, major=동작 상이, minor=스타일 편차)를 부여한다.
- 헤드리스 렌더(playwright-core+msedge, scratchpad repro 스크립트 패턴)로 실제 화면을 찍어 육안 대조 근거를 남긴다.
## 입력/출력
- 입력: 감사 범위(전체 또는 특정 화면), 이전 감사 결과(`_workspace/wiseui_audit.md` — 있으면 델타만).
- 출력: `_workspace/wiseui_audit.md` — 위반 매트릭스(파일쌍·심각도·정렬 방법 1줄) + 우선순위 정렬 백로그(웨이브 분할안).
## 재호출 지침
이전 감사 파일이 있으면 해소된 항목을 ✅ 처리하고 신규 위반만 추가한다(전면 재작성 금지).
## 협업
- 산출 백로그는 kintex-wise-ui-dev가 소비한다. 정렬 불가/예외 후보는 별도 절로 분리해 오케스트레이터 판단에 올린다.

View File

@ -0,0 +1,31 @@
---
name: kintex-wise-ui-dev
description: 킨텍스 UI를 WISE(UIWS) 구조로 정렬 이식하는 전담 구현자. 셸(햄버거·아코디언 메뉴 IA·푸터)·캘린더(CalendarView)·대시보드 차트·테이블/폼 컨벤션·반응형을 UIWS 정본 구조 그대로 kintex(kx 토큰)로 이식할 때 사용한다. 다시 실행·특정 화면만·업데이트·보완 포함.
tools: Read, Write, Edit, Glob, Grep, Bash
model: opus
---
# kintex-wise-ui-dev — WISE 정렬 구현자
## 핵심 역할
kintex-wise-ui-auditor의 위반 백로그를 받아, kintex 프론트를 **UIWS 정본 구조 그대로** 이식·정렬한다.
## 작업 원칙
- **구조 수준 이식**: UIWS 해당 컴포넌트(AppLayout·Header·LeftNav·Footer·CalendarView·layout.css 등)의 마크업 구조·상태 흐름·동작을 그대로 옮기고, 클래스/토큰만 kx-* 로 번역한다. 레이아웃을 "참고해서 새로 만드는" 것 금지.
- 브랜드: 좌상단 로고는 KINTEX CI 자산(`src/frontend/public/brand/`, 원본 `ci/CI_JPG/`) — 글자 마크 임의 제작 금지.
- 메뉴: 아코디언 그룹 트리(WISE LeftNav/menuTree 패턴). 신규 라우트가 생기면 메뉴 트리에 반드시 편입(누락 금지). 시스템관리 그룹은 관리자에게만 노출(useIsAdmin).
- 다크 토큰·i18n(`shell.*`/화면 네임스페이스 4개 언어)·이모지 금지(선 SVG)·MDI 탭바 공존·기존 라우트 회귀 금지.
- 공유 파일 소유권: components/layout·screens/home은 이 에이전트 관할. App.tsx는 통합자(라우트 배선 요청만 노트에).
## 검증(필수)
`npx tsc -b --force` EXIT 0 · `npx vite build` EXIT 0 · 가능하면 헤드리스 렌더 스팟(JS 에러 0).
## 입력/출력
- 입력: `_workspace/wiseui_audit.md`(백로그) 또는 오케스트레이터의 화면 목록.
- 출력: 정렬된 코드 + `_workspace/impl_wiseui_{wave}.md`(이식 매핑표 UIWS→kx·잔여·배선 요청).
## 재호출 지침
이전 impl 노트가 있으면 그 잔여부터 처리한다. 소유자 피드백 문구는 그대로 인용해 해소 여부를 노트에 명시한다.
## 협업
완료 시 kintex-qa(경계면·시각 대조)가 검증한다. 백엔드 API가 필요하면 kintex-backend-dev에 계약을 요청(직접 백엔드 수정 금지).

View File

@ -0,0 +1,35 @@
---
name: kintex-benchmark-orchestrator
description: 킨텍스 벤치마킹·크롤링 오케스트레이터 — 레퍼런스 사이트(COEX·BEXCO·전시테크 등) 크롤·분석·벤치마크 리포트·개선 백로그 산출과 외부 데이터(행사·포스터) 크롤 적재를 총괄. "벤치마킹", "사이트 분석해서 재구성", "경쟁사 비교", "크롤링 하네스", "행사정보 크롤링/갱신", "데이터 수집", "다시 실행", "특정 사이트만", "업데이트", "보완" 요청 시 반드시 이 스킬을 사용하라.
---
# kintex-benchmark-orchestrator — 벤치마킹·크롤링 총괄
**실행 모드: 서브 에이전트**(사이트별 병렬 분석 → 통합 백로그). 모든 Agent 호출 `model: "opus"`.
## 에이전트
| 에이전트 | 역할 |
|---|---|
| kintex-benchmark-analyst | 사이트 크롤·IA/기능/UX 벤치마크 리포트(`docs/analysis/{site}-website.md`)·개선 백로그 |
| kintex-crawler-dev | 데이터 크롤 → 멱등 시드(V{n}) — 행사·포스터·상세 |
| planner (재사용) | 백로그의 기획 반영(PLANNING.md — 명칭·IA·기능은 planner 단일 소유) |
## Phase 0: 컨텍스트
`docs/analysis/*-website.md`·`_workspace/benchmark_backlog.md` 확인 → 초기/델타. 모드 판별: **분석 모드**(벤치마킹) vs **수집 모드**(데이터 크롤 — 즉시 crawler-dev, 마이그레이션 번호는 통합자가 최신+1 지정).
## Phase 1: 분석 (사이트별 병렬)
kintex-benchmark-analyst × 사이트 — 갭 표(있음/부분/없음+차용 권고) 산출.
## Phase 2: 백로그 통합
분석 리포트들을 `_workspace/benchmark_backlog.md`로 통합(우선순위·담당 하네스 매핑: 기획=planner / UI=wise-ui·renewal / 기능=impl). 기획 변경 필요분은 planner 호출.
## Phase 3: 적용 연계
백로그를 해당 하네스로 핸드오프(이 하네스는 분석·수집까지 — 구현은 담당 하네스). 데이터 수집분은 통합자 검증(라이브 upsert 확인) 후 마감.
## 에러
크롤 차단/실패 사이트는 WebSearch 보조 → 그래도 불가 시 잔여 명시. 시드 충돌은 멱등 스킵+보고.
## 테스트 시나리오
- 정상: "COEX 벤치마킹" → 분석 → 백로그 → planner 반영.
- 수집: "최신 행사 크롤 갱신" → crawler-dev(V신규) → 검증.
- 에러: 로그인 필요 페이지 → 공개 범위만 분석·한계 명시.

View File

@ -0,0 +1,31 @@
---
name: kintex-menu-recompose
description: 킨텍스 좌측 메뉴를 카테고리별로 재구성·정합하는 절차 스킬. "메뉴 재구성", "카테고리별 메뉴", "메뉴 정리", "메뉴에 없다/누락", "신규 화면 메뉴 편입", "메뉴 순서 변경", 다시 실행·업데이트·보완 요청 시 kintex-menu-ia-dev가 반드시 이 스킬을 따라 수행하라.
---
# kintex-menu-recompose — 카테고리별 메뉴 재구성 절차
## 표준 카테고리 (v1 — 2026-07-12, 소유자 IA 지시 반영처)
| 그룹 | 포함 기준 | 현행 대표 라우트 |
|---|---|---|
| 홈 | 랜딩 | /home |
| 전시 운영 | 행사 생애주기 운영 | /schedule·/halls·/booth-sales·/ops/operations |
| 설계·시공 | 부스 설계~시공·물류 | 플로어플랜(행사 스코프)·/docs·/auctions·/logistics·/contractor/dashboard |
| 관람객·마케팅 | 대외 접점 | /visitors·/visitors/checkin·/leads·/campaigns·/sponsorship·/cms |
| 정산·분석 | 돈·지표 | /settlement·/analytics |
| 업무 공통 | §5B 공통(결재 포함) | /work/* (worklog·schedule·message·notice·meeting·report·opinion·search·approval) |
| 시스템관리 | 관리자 전용(useIsAdmin 게이트) | /admin/* 전부 |
분류 규칙: ①사용자 여정 기준(누가 언제 쓰나) ②한 화면은 한 그룹(중복 배치 금지) ③그룹 7±2 유지 — 넘치면 소유자에게 분할안 제안 ④애매하면 UIWS menuTree의 대응 그룹을 따른다.
## 절차
1. **라우트 인벤토리**: `grep -n 'path="' src/frontend/src/App.tsx` 전수 + 행사 스코프 빌더(EVENT_SCOPED_PATH) 목록화.
2. **현행 메뉴 트리 추출**: AppShell(또는 menuTree 상수)의 그룹·항목 파싱.
3. **양방향 대조**: 메뉴 없는 라우트(누락) / 라우트 없는 메뉴(dead) / 잘못된 그룹 배치 목록화. 공개(/public·/tickets)·내부(/_styleguide)·상세 딥링크(:id 하위)는 메뉴 제외 대상으로 명시.
4. **재구성**: 표준 카테고리에 맞춰 메뉴 상수 수정(아코디언 그룹·아이콘·역할 게이트·i18n 라벨 4개 언어).
5. **검증**: tsc·vite EXIT 0 + 헤드리스 렌더에서 그룹 펼침·이동 스팟.
6. **보고**: `_workspace/menu_ia_report.md` 갱신(편입/이동/제외 근거 포함).
## 상시 규칙
- 새 화면을 만드는 모든 웨이브는 이 스킬 기준으로 메뉴 편입까지가 완료 정의다(누락 = 미완).
- 소유자가 카테고리를 바꾸면 위 표를 갱신하고 버전을 올린다(변경 이력 주석).

View File

@ -0,0 +1,45 @@
---
name: kintex-renewal-orchestrator
description: 킨텍스 전면 리뉴얼 오케스트레이터 — 내비게이션(브레드크럼·상세→메인 복귀·MDI)·전 화면 WISE 정렬·마스터/테스트 데이터 전면 생성(공통코드·프로그램·부서·데모 폐루프)·QA·배포를 총괄. "리뉴얼", "전면 개편", "전체적으로 다시", "상세에서 메인 못 감/내비 문제", "테스트 데이터 생성", "데이터 채워", "화면이 비어 있다", "공통코드/프로그램 관리 채움", "다시 실행", "특정 영역만", "업데이트", "보완" 요청 시 반드시 이 스킬을 사용하라.
---
# kintex-renewal-orchestrator — 전면 리뉴얼 총괄
소유자 지시(2026-07-12): "상세페이지에서 메인으로 갈 방법이 없다. 전체적으로 리뉴얼해라 — 테스트 데이터·공통코드·프로그램 관리 등등." UI(WISE 정렬)와 **데이터 파운데이션**(마스터+데모 폐루프)을 함께 리뉴얼한다.
**실행 모드: 서브 에이전트 파이프라인**(웨이브별 병렬, 폴더 소유권 분리). 모든 Agent 호출 `model: "opus"`.
## 에이전트(전원 기존 재사용 + testdata 1 신규)
| 에이전트 | 역할 |
|---|---|
| kintex-wise-ui-auditor | 전 화면 WISE 정합 + **내비게이션 감사**(브레드크럼·복귀 경로·dead end) |
| kintex-wise-ui-dev | 셸 내비(브레드크럼=클릭 가능한 경로·홈 복귀·뒤로가기)·화면군 정렬 웨이브 |
| kintex-menu-ia-dev | 메뉴 IA 정합(kintex-menu-recompose) |
| kintex-testdata-dev | 마스터(공통코드·프로그램·부서·회사)+데모 폐루프 시드 |
| kintex-qa (재사용) | 헤드리스 전 화면 스팟(빈 화면 0·dead end 0)·빌드 게이트 |
## Phase 0: 컨텍스트
`_workspace/renewal_*`·`wiseui_audit.md` 확인 → 초기/부분/델타. 소유자 지적은 blocker 직행.
## Phase 1: 내비게이션 리뉴얼 (최우선 — 소유자 지적)
wise-ui-dev — ①상단 브레드크럼을 **클릭 가능한 실제 경로**(홈 > 그룹 > 화면, 각 세그먼트 이동)로 교체(현재 정적 텍스트) ②상세/딥링크 화면(행사 스코프 포함)에 뒤로/홈 복귀 확보(브레드크럼+MDI 탭이 1차, 화면 내 "목록으로" 버튼 컨벤션) ③404·dead end 라우트 정리(`*` 폴백을 로그인 아닌 홈/404 페이지로 — 인증 상태별 분기).
## Phase 2: 데이터 파운데이션
kintex-testdata-dev — 마스터(공통코드 그룹/값·프로그램 등록부(전 라우트)·부서 트리·회사·요율)+데모 폐루프(행사 축: 부스·참가업체·서류·옥션·리드·체크인·캠페인·청구/수납·결재·공지·알림). 멱등 시드 1개(번호는 통합자 지정).
## Phase 3: 화면군 리뉴얼 웨이브
auditor 감사 → wise-ui-dev 웨이브(도메인 화면군 단위, 병렬 시 폴더 분리). UIWS 대응물 없는 화면은 셸/공통 수준만.
## Phase 3.5: 메뉴 IA
kintex-menu-ia-dev — 신규/이동 라우트 편입(누락=미완).
## Phase 4: QA·배포
kintex-qa — 헤드리스 전 라우트 순회(로그인→각 메뉴 클릭: 렌더 오류 0·빈 화면(데이터 有 모듈) 0·복귀 경로 확인) + tsc·vite·compileJava·test. 통과 후 커밋·push(자동배포)·라이브 검증·WORK_STATUS 갱신은 메인 통합자.
## 데이터 전달 / 에러
파일 기반 `_workspace/renewal_{phase}_{agent}.md`. 1회 재시도, 재실패는 잔여 명시. 빌드 게이트 실패는 반려.
## 테스트 시나리오
- 정상: "전체 리뉴얼" → P1 내비 → P2 데이터 → P3 화면군 → P4 QA·배포.
- 부분: "테스트 데이터만 다시" → P2만. / "브레드크럼만" → P1만.
- 에러: 시드가 기존 실데이터와 충돌 → INSERT 스킵(멱등)·충돌 목록 보고.

View File

@ -0,0 +1,49 @@
---
name: kintex-wise-ui-orchestrator
description: 킨텍스 UI를 WISE(UIWS) 컨벤션에 문자 그대로 정렬하는 전담 오케스트레이터. "WISE처럼/WISE대로", "UI 정렬", "메뉴 재구성/아코디언 메뉴", "햄버거 메뉴", "셸/사이드바/푸터 수정", "캘린더 WISE", "대시보드 구성", "차트 넣어", "로고 교체", "반응형 깨짐", "화면 WISE 감사", "다시 실행", "특정 화면만", "업데이트", "보완" 요청 시 반드시 이 스킬을 사용하라. (신규 화면 신설 자체는 designer→Stitch 경유가 선행 — 이 하네스는 기존 화면의 WISE 정렬 전담.)
---
# kintex-wise-ui-orchestrator — WISE UI 정렬 오케스트레이터
킨텍스 프론트 전체를 **WISE(UIWS, `C:\GUARDiA\workspace\uiws\frontend`) 컨벤션에 문자 그대로** 정렬한다. 소유자 원칙(2026-07-12, 메모리 `ui-follow-wise-strictly`): **자체 재해석 금지 — UIWS 구조 이식**.
**실행 모드: 서브 에이전트** (감사→정렬→QA 파이프라인, 결과 전달만으로 충분). 모든 Agent 호출은 `model: "opus"`.
## 에이전트
| 에이전트 | 역할 |
|---|---|
| kintex-wise-ui-auditor | UIWS 대비 위반 매트릭스·정렬 백로그(`_workspace/wiseui_audit.md`) |
| kintex-wise-ui-dev | UIWS 구조 이식 구현(웨이브 단위) |
| kintex-qa (재사용) | 경계면·시각 대조(헤드리스 스크린샷 vs UIWS/Stitch 시안)·빌드 게이트 |
| kintex-frontend-dev (재사용) | 도메인 화면 내부 로직이 얽힌 정렬 시 협업 |
| kintex-menu-ia-dev | 카테고리별 메뉴 재구성·라우트↔메뉴 정합(전담 스킬 kintex-menu-recompose 준수) |
## Phase 0: 컨텍스트 확인
1. `_workspace/wiseui_audit.md`·`_workspace/impl_wiseui_*.md` 존재 확인 → 초기 / 부분 재실행(특정 화면·특정 지적만) / 델타 감사 판별.
2. 소유자 지적이 새로 들어온 경우, 감사 없이 해당 항목을 즉시 정렬 웨이브로 직행할 수 있다(지적 = blocker 확정).
## Phase 1: 감사
kintex-wise-ui-auditor 호출 — 전 화면(또는 지정 범위) UIWS 교차 대조 → 위반 매트릭스 + 웨이브 분할안. 소유자 기지적 항목은 blocker로 선반영.
## Phase 2: 정렬 (웨이브 반복)
kintex-wise-ui-dev를 웨이브 단위(셸→공통 컴포넌트→화면군)로 호출. 병렬 시 폴더 소유권 분리(layout/home vs 개별 screens 폴더군). 각 웨이브 게이트: `tsc -b --force`·`vite build` EXIT 0.
- App.tsx 라우트/메뉴 편입은 통합자(메인)가 dev 노트의 배선 요청으로 처리.
- UIWS에 대응물이 없는 kintex 고유 화면(플로어플랜 에디터 등)은 예외 목록으로 관리 — 셸·공통 컴포넌트 수준만 정렬.
## Phase 2.5: 메뉴 IA 정합
신규/이동 라우트가 있으면 kintex-menu-ia-dev 호출(kintex-menu-recompose 절차) — 메뉴 편입 누락 = 웨이브 미완.
## Phase 3: QA·마감
kintex-qa — ①빌드 게이트 ②헤드리스 렌더 스크린샷을 UIWS 실화면/Stitch 시안과 육안 대조(레이아웃 구조 동일성) ③기존 라우트·MDI·다크·i18n 회귀 0 확인. 통과 후 커밋·자동배포(push)·라이브 검증·WORK_STATUS 갱신은 메인 통합자.
## 데이터 전달
파일 기반(`_workspace/wiseui_audit.md`·`impl_wiseui_{wave}.md`) + Agent 반환값. 실패 시 1회 재시도, 재실패는 잔여 백로그로 명시하고 진행.
## 에러 핸들링
- 빌드 게이트 실패: 해당 dev에 반려(통과까지).
- UIWS와 kintex 요구가 충돌(도메인 특수성): 삭제하지 않고 예외 후보로 기록 → 소유자 확인.
## 테스트 시나리오
- 정상: "메뉴 아코디언 WISE처럼 재구성" → Phase 0(지적 직행) → wise-ui-dev(셸 웨이브) → qa → 배포.
- 부분: "캘린더만 WISE로" → 부분 재실행(dev 캘린더 항목만) → qa.
- 에러: UIWS에 없는 화면 정렬 요청 → 예외 목록 보고 + 셸 수준만 정렬.

View File

@ -1,12 +1,15 @@
# 킨텍스 자동전시시스템 (Exhibition Automation Platform) 기획서 # 킨텍스 AI 전시·행사시스템 (KINTEX AI Exhibition & Event System) 기획서
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 버전: **v3.1** > 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-12 · 버전: **v3.2**
> **제품 공식 명칭(소유자 확정 2026-07-12): "KINTEX AI 전시·행사시스템"** — 기존 "자동전시시스템(Exhibition Automation Platform)"을 대체한다. 전시(exhibition)뿐 아니라 행사·이벤트(event) 전반을 포괄한다는 의미를 명칭에 반영. 본 명칭이 정본이며, 이하 본문·3-트랙 기획·타 문서 후속 반영 시 이 명칭을 사용한다(기존 "자동전시시스템" 표기는 문맥 보존을 위해 삭제하지 않고 잔존하나, 신규 표기는 정본 명칭을 따른다).
> 근거 문서: `docs/analysis/kintex-website.md` (킨텍스 웹사이트 25개 페이지 + 전시주최자매뉴얼 PDF + 참가업체 매뉴얼 PDF 전수 분석, 2026-07-11) > 근거 문서: `docs/analysis/kintex-website.md` (킨텍스 웹사이트 25개 페이지 + 전시주최자매뉴얼 PDF + 참가업체 매뉴얼 PDF 전수 분석, 2026-07-11)
> 근거 문서: `docs/analysis/coex-website.md` (코엑스 VISITOR/BUSINESS/CYBER 3-사이트 IA 분석 + 킨텍스 대비표, 2026-07-12) — §2-2 대외 접점 3-트랙 재구성 확정 근거
> 근거 문서: `docs/analysis/reroomai-source.md` (ReRoomAI 이미지 파이프라인 소스 분석, 2026-07-11) — 6장 나노바나나 파이프라인 제어 파라미터 확정 근거 > 근거 문서: `docs/analysis/reroomai-source.md` (ReRoomAI 이미지 파이프라인 소스 분석, 2026-07-11) — 6장 나노바나나 파이프라인 제어 파라미터 확정 근거
> 근거(글로벌 벤치마크, 2026-07-11 크롤링): Eventleaf·VenueSight·Whova·ExpoPlatform(전시관리·부스판매·리드캡처), Eventbase·Pointr·Swapcard·Brella·Grip(wayfinding·비즈매칭), RainFocus·Bizzabo(등록·리벤뉴), ExhibitForce·Ways·FinancialModelsLab·Digitevent(전시 BI/KPI), FindRFP·Procore·4castplus(RFQ/Tender/역경매). 각 §5 모듈에 매핑. > 근거(글로벌 벤치마크, 2026-07-11 크롤링): Eventleaf·VenueSight·Whova·ExpoPlatform(전시관리·부스판매·리드캡처), Eventbase·Pointr·Swapcard·Brella·Grip(wayfinding·비즈매칭), RainFocus·Bizzabo(등록·리벤뉴), ExhibitForce·Ways·FinancialModelsLab·Digitevent(전시 BI/KPI), FindRFP·Procore·4castplus(RFQ/Tender/역경매). 각 §5 모듈에 매핑.
> >
> **v2.0 정체성 확장**: 본 시스템은 "부스 시공 도구"를 넘어 **킨텍스 자동전시시스템(Exhibition Automation Platform)** 이다 — 전시 기획·판매·시공·운영·관람객·사후분석 전 주기를 아우른다. 기존 M2~M5 부스 시공 코어(P0)·나노바나나 파이프라인(§6)·확정 스택(§8)은 **보존**하고, 그 위에 관람객/비즈매칭/마케팅·공개사이트/wayfinding/현장운영/공사 옥션/BI/CMS/관리자 모듈(M10~M18)을 확장한다. > **v2.0 정체성 확장**: 본 시스템은 "부스 시공 도구"를 넘어 **킨텍스 자동전시시스템(Exhibition Automation Platform)** 이다 — 전시 기획·판매·시공·운영·관람객·사후분석 전 주기를 아우른다. 기존 M2~M5 부스 시공 코어(P0)·나노바나나 파이프라인(§6)·확정 스택(§8)은 **보존**하고, 그 위에 관람객/비즈매칭/마케팅·공개사이트/wayfinding/현장운영/공사 옥션/BI/CMS/관리자 모듈(M10~M18)을 확장한다.
> **v3.0 정체성 확장 — 다중 전시관 SaaS**: 본 시스템은 **킨텍스 전용에서 다중 전시관(Venue) 멀티테넌트 SaaS**로 확장한다. 킨텍스(KINTEX)는 **기준 테넌트(테넌트 #1)** 로 유지되고, 코엑스(COEX) 등 여타 전시관을 테넌트로 온보딩한다. 기능(M1~M18·§5B·§6 나노바나나·§8 확정 스택)은 **전부 보존**하고, 그 위에 **테넌트 격리 레이어(tenant_id 전파·컨텍스트 해소·역할 계층)** 만 얹는다 — 기능은 그대로, 데이터·권한·컨텍스트가 전시관 단위로 분리된다. 상세는 **§1A**, 격리 전략은 **§8-2**. > **v3.0 정체성 확장 — 다중 전시관 SaaS**: 본 시스템은 **킨텍스 전용에서 다중 전시관(Venue) 멀티테넌트 SaaS**로 확장한다. 킨텍스(KINTEX)는 **기준 테넌트(테넌트 #1)** 로 유지되고, 코엑스(COEX) 등 여타 전시관을 테넌트로 온보딩한다. 기능(M1~M18·§5B·§6 나노바나나·§8 확정 스택)은 **전부 보존**하고, 그 위에 **테넌트 격리 레이어(tenant_id 전파·컨텍스트 해소·역할 계층)** 만 얹는다 — 기능은 그대로, 데이터·권한·컨텍스트가 전시관 단위로 분리된다. 상세는 **§1A**, 격리 전략은 **§8-2**.
> **v3.2 대외 접점 3-트랙 재구성**: 코엑스(COEX) IA 분석(3-사이트 분리: VISITOR/BUSINESS/CYBER)을 근거로, 대외 접점을 **visitor(관람객) / business(비즈니스=주최자·참가업체) / agency(에이전시=공사·장치·협력사)** 3개 트랙으로 분리·재편한다. 기존 6역할·7페르소나(§2)·포털 매트릭스(§2-1)·모듈(M1~M18)·화면(SCR-*)은 **전부 보존**하고, 그 위에 **트랙 셸(track shell) — 랜딩 3-분기 + 트랙별 서브홈 + 트랙-스코프 로그인 후 홈** IA 레이어만 얹는다. 상세는 **§2-2**(내부 역할 ops·admin은 대외 트랙 외부에 그대로 유지).
> **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다. > **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다.
--- ---
@ -183,6 +186,77 @@
--- ---
## 2-2. 대외 접점 3-트랙 재구성 (visitor / business / agency) — v3.2
> **소유자 확정(2026-07-12)**: 대외 접점을 **visitor(관람객) / business(비즈니스=주최자·참가업체) / agency(에이전시=공사·장치·협력사)** 3개 트랙으로 나눠 분리한다. 근거 = `docs/analysis/coex-website.md` — 코엑스가 방문자 유형별로 **사이트(도메인) 자체를 3개(VISITOR `coex.co.kr` / BUSINESS `business.coex.co.kr` / CYBER 참가신청 `cybercoex.co.kr`)로 분리**한 IA를 킨텍스 도메인에 이식한다.
>
> **보존 원칙**: 본 절은 §2 페르소나·§2-1 역할별 포털 매트릭스·§2-1-1 계정 체계·M1~M18·§8 아키텍처를 **삭제·재작성하지 않는다.** 6역할/7페르소나 구조는 유지되고, 그 위에 "대외 접점을 3개 트랙으로 묶어 진입점·서브홈·네비게이션 IA를 재편하는" **트랙 셸 레이어**만 순증한다. 내부 전용 역할(킨텍스 직원 ops·테넌트/플랫폼 관리자 admin)은 대외 트랙이 아니므로 3-트랙 밖에 그대로 둔다.
### 2-2-1. 3-트랙 정의 — 대상·핵심 여정·진입 화면·제공 기능
| 트랙 | 대상(§2 역할 매핑) | 코엑스 대응 | 핵심 여정 | 진입 화면 | 제공 기능(모듈·SCR) |
|---|---|---|---|---|---|
| **visitor** (관람객) | 관람객 + 일반 대중(Public) | VISITOR `coex.co.kr` + 가이드 | 행사 탐색 → 사전등록/티켓 → 방문(교통·주차·길찾기) → 배지·체크인 → 관람·매칭 → 재방문 | visitor 서브홈(공개 랜딩) → SCR-P1 | 공개 홍보 사이트 M12(SCR-P1·P2·P3·P5), 사전등록·티켓 M10(SCR-P4·P6·P7·P8), wayfinding M13, 비즈매칭 M11, 관람객 앱(SCR-M5~M9·M14·M15) |
| **business** (비즈니스) | 주최자(Organizer) + 참가업체(Exhibitor) | BUSINESS `business.coex.co.kr` + CYBER `cybercoex.co.kr` | (주최자) 홀배정·견적 → 배치·서류 → 옥션 발주 → 정산·BI / (참가업체) 부스 신청 → 설계·유틸리티 → 시각화 → 리드 | business 서브홈 → 로그인 → 역할별 홈(주최자 SCR-02 / 참가 SCR-05) | 홀배정·견적 M1, 배치 M2, 부스설계 M3, 유틸리티 M4, 시각화 M5, 서류·마일스톤 M6, 옥션 발주 M15, 정산 M9, 관람·리드 M10, BI M16 |
| **agency** (에이전시) | 장치·공사업체(Contractor) + 등록·협력업체 | BUSINESS 하위 **서비스협력업체** + 안전경영 **온라인작업신고** (킨텍스는 독립 트랙으로 승격) | 등록업체 검증 → AI 설계자료 열람 → **옥션 응찰(견적서 제출)** → 수주 → 도면 제출·규정검증 → 현장 시공·작업신고 | agency 서브홈 → 로그인 → 수주/응찰 대시보드(SCR-27/28) | 부스설계(공유) M3, 규정검증 M3(SCR-09), 옥션 응찰 M15(SCR-27·28), 등록업체 매칭 M7(SCR-38), 반입/작업신고 M8, 현장 앱(SCR-M1·M10) |
- **트랙 ↔ 역할 관계**: 트랙은 역할의 **상위 묶음(진입점 그룹)** 이지 역할의 대체가 아니다. business 트랙 안에 organizer·exhibitor 2역할이 공존하고, agency 트랙 안에 contractor + 등록업체 계정이 공존한다. 로그인 후 실제 권한·화면은 기존 §2 역할 RBAC 그대로.
- **코엑스와의 차이(정당화)**: 코엑스는 agency(협력사)를 BUSINESS 하위로 흡수했으나, 킨텍스는 **독립부스 등록 장치업체 필수·미등록 시공 엄금·리깅 구조계산서·739개 등록업체 DB·M15 역경매 옥션**이라는 강한 도메인이 있어 agency를 **독립 트랙**으로 세운다(§4 대비표 근거).
### 2-2-2. 코엑스에서 차용할 요소 (트랙별)
| 트랙 | 코엑스 차용 요소 | 킨텍스 반영 |
|---|---|---|
| visitor | "가이드" 단일 허브(오시는 길·주차·실내 길찾기·VR·편의시설·알림마당·문의) + 관심분야 맞춤 알림 + 수어/접근성 전면 노출 | visitor 서브홈에 방문 가이드 허브 구성(교통 GTX-A·주차 iparking·wayfinding M13·편의시설), 맞춤 알림·접근성 고지(M12·M10) |
| business | 임대절차→시설규격→요금→서류/도면 **단계별 실무 흐름** + 참가업체 신청 **공통 플랫폼(cybercoex)** 일원화 | business 서브홈에 주최자(대관·M1)·참가업체(신청·M3/M4) 단계 안내 + 킨텍스 최대 공백인 **참가업체 공통 e-서비스**를 business 트랙으로 통합 |
| agency | 서비스협력업체 리스트 + 안전경영 온라인작업신고 통합 | agency 서브홈에 등록업체 검증·수주·옥션·도면제출·작업신고(kxwp 릴레이)를 하나로(코엑스보다 강한 독립 트랙) |
### 2-2-3. 공개 사이트 구조 — 랜딩 3-분기 + 트랙별 서브홈 + 로그인 후 홈
**(A) 랜딩(루트) = 3-트랙 분기 관문.** 최상위 진입점(예: `www.` 또는 테넌트 루트)은 코엑스 스타일로 **visitor / business / agency 3개 카드로 명확히 분기**하는 관문 랜딩을 제공한다. 관람객이 절대다수이므로 visitor를 시각적 1순위(히어로·행사 카드)로 두고, business·agency는 상단 유틸리티/카드로 노출(코엑스가 VISITOR를 메인에 두고 BUSINESS를 링크로 분리한 패턴 차용).
**(B) 트랙별 서브홈(전용 공개 홈 3개).**
- **visitor 서브홈** — 공개·비로그인. 행사 일정·히어로·사전등록/티켓 CTA·방문 가이드 허브(SCR-P1 확장). SEO·다국어·MDI 비적용(§8-1).
- **business 서브홈** — 공개(안내) + 로그인 유도. 주최자 대관 안내·참가업체 참가 안내·요금·서류 안내(SCR-P6 확장) → "로그인/신청" CTA.
- **agency 서브홈** — 공개(안내) + 등록업체 로그인. 등록업체 안내·진행 중 옥션 공고·작업신고 안내 → 등록업체 로그인 CTA.
**(C) 로그인 후 랜딩 — 권고: "트랙-스코프 홈(Track-scoped Home)".**
로그인 후 홈은 **단일 범용 홈(역할별 변형)** vs **트랙별 별도 홈** 두 안 중, **트랙별 별도 홈(단, 트랙 내부는 역할 변형)** 을 권고한다.
| 안 | 내용 | 장점 | 단점 | 판정 |
|---|---|---|---|---|
| 단일 범용 홈(역할별 위젯 변형) | 로그인 후 하나의 홈, 역할에 따라 위젯만 다름 | 구현 단순, 계정 승격 연속성(§2-1-1) 매끄러움 | 트랙 정체성 약함, business·agency·visitor 혼재로 IA 혼란 | 부분 채택(트랙 내부) |
| **트랙별 별도 홈(트랙 내 역할 변형)** | 트랙 셸마다 홈, 홈 안에서 역할(주최자/참가 · 시공/등록)로 위젯 변형 | 코엑스식 트랙 정체성·최소권한·공격면 축소(§2-1), 기존 SCR-02/05/27 재사용 | 홈 3벌 유지 | **권고** |
- **권고 근거**: business 트랙은 organizer(SCR-02)·exhibitor(SCR-05) **기존 홈을 그대로** 트랙 내 역할 변형으로 재사용하고, agency는 수주/응찰 대시보드(SCR-27/28)를 홈으로 삼는다 → **신규 홈 화면 최소**(트랙 관문 랜딩 + 서브홈 3개만 신규, 나머지는 재배치). 계정 승격 연속성(§2-1-1)은 **단일 통합 계정** 위에서 유지되므로 트랙별 홈이어도 데이터 연속성은 훼손되지 않는다(관람객→바이어/참가 승격 시 business 트랙 접근 권한만 추가).
### 2-2-4. 라우트 체계 — 마이그레이션 비용 비교 후 권고
| 안 | 라우트 | 마이그레이션 비용 | SEO/딥링크 | 판정 |
|---|---|---|---|---|
| A. 전면 트랙 프리픽스 | 기존 `/o/`·`/e/`·`/c/`·`/tickets/`를 `/business/o/`·`/business/e/`·`/agency/c/`·`/visitor/tickets/`로 재작성 | **높음** — 전 딥링크·MDI 세션키(§design 2.7 `kintex.mdi.{role}.{eventId}`)·티켓 공개 라우트(`/tickets/:orderNo`) SEO 인덱스 전면 변경, 리다이렉트 맵 필요 | 기존 인덱스 무효화 위험 | 미채택 |
| **B. 트랙 셸 레이어 + 기존 딥 라우트 유지(하이브리드)** | **신규 얕은 트랙 진입 라우트만 추가**`/`(관문 랜딩), `/visitor`(서브홈), `/business`(서브홈), `/agency`(서브홈). 로그인 후 **기존 역할 딥 라우트(`/o/{eventId}/...`·`/e/{eventId}/...`·`/m/...`·`/gallery/...`·`/tickets/...`)는 그대로 유지** | **낮음** — 기존 화면·MDI·티켓 SEO·세션키 불변, 트랙은 상위 셸/네비로만 그룹핑 | 기존 인덱스 보존, 신규 트랙 랜딩만 인덱싱 | **권고** |
| C. 서브도메인 분리 | `visit.` / `business.` / `agency.` 서브도메인(코엑스식) | 중간 — 배포·인증서·SSO 쿠키 도메인 조정, 단 §2-1·§8-1이 이미 역할 서브도메인(organizer.·exhibitor.·contractor.) 전제 | 트랙별 브랜딩·격리 명확 | **선택적 상위 옵션**(운영 단계) |
- **권고 = 안 B(하이브리드) + 안 C 선택 결합**: 1차는 **경로 기반 트랙 셸(안 B)** 로 마이그레이션 비용 없이 트랙 IA를 얹고, 기존 딥 라우트(`/o/`·`/e/`·`/m/`·`/tickets/`)와 MDI 셸(design §2.7)·티켓 공개 라우트(SCR-P7/P8)를 **불변**으로 둔다. 운영 확장 시 §2-1의 역할 서브도메인 전략과 정합하여 트랙 서브도메인(안 C)을 상위에 얹을 수 있다(예: `business.` → business 서브홈 → 내부적으로 organizer/exhibitor 앱).
- **트랙 ↔ 기존 서브도메인 매핑**(§2-1 보존): visitor 트랙 ⊇ {`www/expo.` 공개 + 관람객 앱}, business 트랙 ⊇ {`organizer.`·`exhibitor.`}, agency 트랙 ⊇ {`contractor.`}. ops·admin 서브도메인은 트랙 외부(내부 전용).
### 2-2-5. 기존 화면 재배치 매핑표 (현행 SCR/라우트 → 트랙)
> 원칙: **화면·라우트는 이동·재작성하지 않는다**(안 B). 아래는 각 화면이 **어느 트랙 셸의 네비게이션·진입 그룹에 속하는가**의 논리적 귀속 표이며, designer/frontend는 이를 트랙 서브홈·네비 구성에만 사용한다.
| 트랙 | 귀속 화면(design.md SCR) | 현행 라우트(유지) | 신규(트랙 셸) |
|---|---|---|---|
| **관문 랜딩** | (신규) 3-트랙 분기 관문 | `/` | ★신규 SCR 필요(designer) |
| **visitor** | SCR-P1·P2·P3·P5(공개사이트), SCR-P4·P6(등록·문의), SCR-P7·P8(티켓), SCR-M5~M9·M14·M15(관람객 앱) | `www/expo.` 공개, `/tickets/*`, 관람객 앱 | ★visitor 서브홈(SCR-P1 확장) |
| **business** | SCR-01(로그인), SCR-02·03·04·19(주최자), SCR-05·06·07·08·12(참가), SCR-15·18·20·21·22·23·24·25·26·29(M1/M6/M8/M9/M15 발주), SCR-13(BI), SCR-30·31·32(관람·리드), SCR-39~48(공통업무) | `/o/{eventId}/*`, `/e/{eventId}/*`, `/gallery/*` | ★business 서브홈(SCR-P6 확장) |
| **agency** | SCR-06·09(설계·규정, 공유), SCR-27·28(옥션 응찰), SCR-38(등록업체), SCR-M1·M10(현장·통행증) | `/c/{eventId}/*`(contractor, 기존 exhibitor 포털 공유 라우트), 옥션 응찰 라우트 | ★agency 서브홈(등록업체·옥션 공고) |
| **트랙 외부(내부)** | SCR-10·11(홀매니저), SCR-14(현장운영), SCR-16·A1~A10(관리자·시스템관리) | `/m/{eventId}/*`, `admin.` | (트랙 재편 대상 아님, 보존) |
- **신규 화면 = 4종만**(관문 랜딩 1 + 서브홈 3). 나머지는 전량 기존 SCR 재사용 → designer/frontend 작업량 최소(§구현 지시서 `_workspace/plan_3track.md`).
---
## 3. 현행(As-Is) 프로세스 분석 ## 3. 현행(As-Is) 프로세스 분석
### 3-1. 주최자 타임라인과 병목 ### 3-1. 주최자 타임라인과 병목
@ -1017,4 +1091,5 @@ graph TB
| v1.2 | 2026-07-11 | planner | **기술 스택 확정 — React + Spring Boot 3.x(Java 17) + MyBatis + PostgreSQL(PostGIS), 나노바나나 Python 워커 사이드카. GUARDiA 표준 프레임워크 정렬(사용자 지정).** §8 아키텍처 전면 정합화: 백엔드 FastAPI→Spring Boot 3.x + MyBatis(REST + WebSocket/STOMP, 룰·배치/배선 엔진=서비스 계층 + PostGIS 공간 SQL), 프론트 Next.js→React 18/19(Vite·TS, SVG/WebGL 캔버스), 비동기 큐 Redis 유지 + 이미지 생성은 별도 **Python 워커 사이드카(`tools/nanobanana`, google-genai Python SDK)** 로 분리(Spring이 RenderJob 큐잉→Python 워커 소비·생성→오브젝트 스토리지 적재→WebSocket 완료 푸시, 서류·알림도 동일 큐). Python 워커 유지 근거 명시(ReRoomAI 검증 client.py·Python SDK — Java 재구현 회피). 인증(행사 단위 RBAC+JWT)·오브젝트 스토리지 유지. §8 mermaid 갱신. **기능 범위(M1~M9)·우선순위·나노바나나 파이프라인 로직 불변** — 스택 표기 정합화만 수행 | | v1.2 | 2026-07-11 | planner | **기술 스택 확정 — React + Spring Boot 3.x(Java 17) + MyBatis + PostgreSQL(PostGIS), 나노바나나 Python 워커 사이드카. GUARDiA 표준 프레임워크 정렬(사용자 지정).** §8 아키텍처 전면 정합화: 백엔드 FastAPI→Spring Boot 3.x + MyBatis(REST + WebSocket/STOMP, 룰·배치/배선 엔진=서비스 계층 + PostGIS 공간 SQL), 프론트 Next.js→React 18/19(Vite·TS, SVG/WebGL 캔버스), 비동기 큐 Redis 유지 + 이미지 생성은 별도 **Python 워커 사이드카(`tools/nanobanana`, google-genai Python SDK)** 로 분리(Spring이 RenderJob 큐잉→Python 워커 소비·생성→오브젝트 스토리지 적재→WebSocket 완료 푸시, 서류·알림도 동일 큐). Python 워커 유지 근거 명시(ReRoomAI 검증 client.py·Python SDK — Java 재구현 회피). 인증(행사 단위 RBAC+JWT)·오브젝트 스토리지 유지. §8 mermaid 갱신. **기능 범위(M1~M9)·우선순위·나노바나나 파이프라인 로직 불변** — 스택 표기 정합화만 수행 |
| **v2.0** | 2026-07-11 | planner | **정체성 확장 — 킨텍스 자동전시시스템(Exhibition Automation Platform).** 글로벌 전시테크 크롤링(Eventleaf·VenueSight·Whova·ExpoPlatform·Eventbase·Pointr·Swapcard·Brella·Grip·RainFocus·ExhibitForce·FindRFP·Procore·4castplus) 근거로 확장. ①§1 비전 재정의(생애주기 폐루프, M15 옥션이 코어를 발주로 연결) ②§2 역할·포털 매트릭스 신설 — 6역할(주최자·참가·업체·홀매니저·관리자·관람객/대중) 웹/모바일 분리(organizer·exhibitor·contractor·ops·admin·public+visitor), 관리자·일반대중 페르소나 추가 ③§4 모듈맵 mermaid 확장(M10~M18) + 우선순위 총괄 ④§5A 신규 모듈 상세: **M15 공사/장치 옥션(P1·핵심, 역경매·견적서(Quotation)=응찰·실시간 순위·종합평가 낙찰·등록업체만 응찰·Auction 1─N Quotation ─ Award·PDF/버전, 입찰 플로우 시퀀스 다이어그램)**, M10 관람객 등록·배지·리드캡처(P1), M12 마케팅·EDM·공개 홍보 사이트(P1·SEO/다국어), M16 경영분석 BI(P2→**P1 승격**, design.md 반영은 designer 후속), M17 CMS(P1), M18 관리자 백오피스(P1), M11 비즈매칭·M13 wayfinding·M14 현장운영(P2) ⑤**M2·M3 3안 생성→선택/병합 UX 구체화**(1·2·3안 다양화, 구역/블록 병합, 병합 후 규정 재검증, 버전 기록) ⑥§7-3 엔티티 확장(Auction/Quotation/Award·Visitor/Badge/Lead·KPI/Content/User) ⑦§8-1 아키텍처 보강(역할별 분리 프론트+SSO/RBAC+공개사이트 SEO/다국어+백오피스+옥션 엔진) ⑧**§5B 공통/시스템관리 레이어(UIWS 표준 이식) 신설** — kintex 스택=UIWS(GUARDiA 표준 프레임워크) 동일 → 시스템관리(사용자·RBAC·공통코드·메뉴·감사로그·시스템설정)와 공통 업무기능(worklog·schedule·message·stats·notice·opinion·search·meeting·report·notification·audit) + 인증(JWT + **2차 인증 OTP TOTP RFC6238** + 로그인 실패 잠금 + admin 비번 env `ADMIN_PASSWORD_ENC` 주입)을 `workspace/uiws` 레퍼런스로 이식, 도메인 모듈은 그 위에 적재. **M18 관리자 시스템을 UIWS 시스템관리와 통합(중복 제거)**, BI/CMS/알림 경계 규칙 정의, Phase 1 선행 기반으로 로드맵 반영. ⑨**§5A M16-1 운영사(킨텍스·venue operator) 관점 수익성/ROI 소절 신설** — 참가업체 관점 ROI(리드 기반)와 명시 구분하고, 킨텍스 관점 7지표(홀·기간별 가동률/매출구성/행사별 P&L·마진/전시장별 ROI·RevPAD·㎡당 수익/참가사 리텐션·LTV/수요예측·수율·가격 최적화/경영진 KPI) + 데이터원(M1·M4·M9·M10·M15) + **BI 데이터마트(스타 스키마 Fact/Dim, DA 후속 트랙)** 정의. **기존 M2~M5 P0 코어·§6 나노바나나 로직·§8 확정 스택 보존.** design.md/타 문서는 미수정 — designer/planner/DA 후속 반영 필요로 표기 | | **v2.0** | 2026-07-11 | planner | **정체성 확장 — 킨텍스 자동전시시스템(Exhibition Automation Platform).** 글로벌 전시테크 크롤링(Eventleaf·VenueSight·Whova·ExpoPlatform·Eventbase·Pointr·Swapcard·Brella·Grip·RainFocus·ExhibitForce·FindRFP·Procore·4castplus) 근거로 확장. ①§1 비전 재정의(생애주기 폐루프, M15 옥션이 코어를 발주로 연결) ②§2 역할·포털 매트릭스 신설 — 6역할(주최자·참가·업체·홀매니저·관리자·관람객/대중) 웹/모바일 분리(organizer·exhibitor·contractor·ops·admin·public+visitor), 관리자·일반대중 페르소나 추가 ③§4 모듈맵 mermaid 확장(M10~M18) + 우선순위 총괄 ④§5A 신규 모듈 상세: **M15 공사/장치 옥션(P1·핵심, 역경매·견적서(Quotation)=응찰·실시간 순위·종합평가 낙찰·등록업체만 응찰·Auction 1─N Quotation ─ Award·PDF/버전, 입찰 플로우 시퀀스 다이어그램)**, M10 관람객 등록·배지·리드캡처(P1), M12 마케팅·EDM·공개 홍보 사이트(P1·SEO/다국어), M16 경영분석 BI(P2→**P1 승격**, design.md 반영은 designer 후속), M17 CMS(P1), M18 관리자 백오피스(P1), M11 비즈매칭·M13 wayfinding·M14 현장운영(P2) ⑤**M2·M3 3안 생성→선택/병합 UX 구체화**(1·2·3안 다양화, 구역/블록 병합, 병합 후 규정 재검증, 버전 기록) ⑥§7-3 엔티티 확장(Auction/Quotation/Award·Visitor/Badge/Lead·KPI/Content/User) ⑦§8-1 아키텍처 보강(역할별 분리 프론트+SSO/RBAC+공개사이트 SEO/다국어+백오피스+옥션 엔진) ⑧**§5B 공통/시스템관리 레이어(UIWS 표준 이식) 신설** — kintex 스택=UIWS(GUARDiA 표준 프레임워크) 동일 → 시스템관리(사용자·RBAC·공통코드·메뉴·감사로그·시스템설정)와 공통 업무기능(worklog·schedule·message·stats·notice·opinion·search·meeting·report·notification·audit) + 인증(JWT + **2차 인증 OTP TOTP RFC6238** + 로그인 실패 잠금 + admin 비번 env `ADMIN_PASSWORD_ENC` 주입)을 `workspace/uiws` 레퍼런스로 이식, 도메인 모듈은 그 위에 적재. **M18 관리자 시스템을 UIWS 시스템관리와 통합(중복 제거)**, BI/CMS/알림 경계 규칙 정의, Phase 1 선행 기반으로 로드맵 반영. ⑨**§5A M16-1 운영사(킨텍스·venue operator) 관점 수익성/ROI 소절 신설** — 참가업체 관점 ROI(리드 기반)와 명시 구분하고, 킨텍스 관점 7지표(홀·기간별 가동률/매출구성/행사별 P&L·마진/전시장별 ROI·RevPAD·㎡당 수익/참가사 리텐션·LTV/수요예측·수율·가격 최적화/경영진 KPI) + 데이터원(M1·M4·M9·M10·M15) + **BI 데이터마트(스타 스키마 Fact/Dim, DA 후속 트랙)** 정의. **기존 M2~M5 P0 코어·§6 나노바나나 로직·§8 확정 스택 보존.** design.md/타 문서는 미수정 — designer/planner/DA 후속 반영 필요로 표기 |
| **v3.0** | 2026-07-11 | planner | **멀티테넌시(다중 전시관 SaaS) 확장 — 킨텍스 전용 → 다중 전시관, KINTEX=기준 테넌트 #1.** 기존 스코프(부스 코어 M2~M5·도메인 M10~M18·§5B 공통 레이어·§6 나노바나나·§8 확정 스택) **전부 보존**, 격리 레이어만 순증. ①헤더 v3.0 정체성 확장(다중 전시관 SaaS) ②**§1A 멀티테넌시 절 신설** — 테넌트=전시관(Venue) 모델(tenant_code·명칭·branding·domain/subdomain·active·locale, 홀·요율·규정 룰셋·부스표준을 테넌트 소유로 재정의)·`tenant_id` 전파 및 격리(전 도메인 엔티티 격리 vs 전역/테넌트 참조 구분)·테넌트 컨텍스트 해소(서브도메인 kintex/coex.wise.ai.kr 우선 + 사용자 소속 보조, fail-closed 주입)·역할 계층(플랫폼 슈퍼관리자 vs 테넌트 관리자 2계층, 행사 RBAC는 테넌트 내부 스코프)·마이그레이션(기존 데이터 tenant_id=1 백필, DDL은 db-engineer/DA 후속) ③**§2 역할 갱신** — 페르소나 관리자 행을 테넌트 관리자로 재정의 + 플랫폼 슈퍼관리자 신규, 권한 모델을 "테넌트 격리 ⊃ 행사 RBAC" 3중 구조로, §2-1 포털 매트릭스 admin 2계층 분리 ④**§7 데이터모델 갱신** — §7-1 마스터데이터 테넌트 스코프 재정의(킨텍스 실측=테넌트#1 시드), §7-3 Tenant/Venue 엔티티·tenant_id 전파·참조 구분·역할 계층·백필 추가 ⑤**§8-2 멀티테넌트 격리 전략 신설** — 공유 스키마 + tenant_id 컬럼(단일 kintex_db 유지·DB/스키마 분리 미채택, 비교표)·fail-closed 다층 강제(필터→서비스가드→MyBatis 공통 인터셉터→PostGIS)·성능(`(tenant_id,…)` 선두 인덱스)·보안(교차 차단·감사·스토리지/쿼터 격리)·온보딩 6단(코드 배포 없이 데이터 온보딩) ⑥§1 Non-Goal에 온보딩 범위 한정 추가. **design.md·architecture/*.md·src 미수정** — db-engineer/DA/designer 후속 반영 필요로 표기. 근거 없는 코엑스 실측 추정 배제(온보딩 시 입력) | | **v3.0** | 2026-07-11 | planner | **멀티테넌시(다중 전시관 SaaS) 확장 — 킨텍스 전용 → 다중 전시관, KINTEX=기준 테넌트 #1.** 기존 스코프(부스 코어 M2~M5·도메인 M10~M18·§5B 공통 레이어·§6 나노바나나·§8 확정 스택) **전부 보존**, 격리 레이어만 순증. ①헤더 v3.0 정체성 확장(다중 전시관 SaaS) ②**§1A 멀티테넌시 절 신설** — 테넌트=전시관(Venue) 모델(tenant_code·명칭·branding·domain/subdomain·active·locale, 홀·요율·규정 룰셋·부스표준을 테넌트 소유로 재정의)·`tenant_id` 전파 및 격리(전 도메인 엔티티 격리 vs 전역/테넌트 참조 구분)·테넌트 컨텍스트 해소(서브도메인 kintex/coex.wise.ai.kr 우선 + 사용자 소속 보조, fail-closed 주입)·역할 계층(플랫폼 슈퍼관리자 vs 테넌트 관리자 2계층, 행사 RBAC는 테넌트 내부 스코프)·마이그레이션(기존 데이터 tenant_id=1 백필, DDL은 db-engineer/DA 후속) ③**§2 역할 갱신** — 페르소나 관리자 행을 테넌트 관리자로 재정의 + 플랫폼 슈퍼관리자 신규, 권한 모델을 "테넌트 격리 ⊃ 행사 RBAC" 3중 구조로, §2-1 포털 매트릭스 admin 2계층 분리 ④**§7 데이터모델 갱신** — §7-1 마스터데이터 테넌트 스코프 재정의(킨텍스 실측=테넌트#1 시드), §7-3 Tenant/Venue 엔티티·tenant_id 전파·참조 구분·역할 계층·백필 추가 ⑤**§8-2 멀티테넌트 격리 전략 신설** — 공유 스키마 + tenant_id 컬럼(단일 kintex_db 유지·DB/스키마 분리 미채택, 비교표)·fail-closed 다층 강제(필터→서비스가드→MyBatis 공통 인터셉터→PostGIS)·성능(`(tenant_id,…)` 선두 인덱스)·보안(교차 차단·감사·스토리지/쿼터 격리)·온보딩 6단(코드 배포 없이 데이터 온보딩) ⑥§1 Non-Goal에 온보딩 범위 한정 추가. **design.md·architecture/*.md·src 미수정** — db-engineer/DA/designer 후속 반영 필요로 표기. 근거 없는 코엑스 실측 추정 배제(온보딩 시 입력) |
| **v3.2** | 2026-07-12 | planner | **제품 명칭 확정 + 대외 접점 3-트랙 재구성(소유자 확정 2026-07-12).** 기존 스코프(M1~M18·§1A 멀티테넌시·§2 6역할/7페르소나·§2-1 포털 매트릭스·§5B 공통레이어·§6 나노바나나·§8 아키텍처·design.md SCR-*) **전부 보존**, IA 레이어만 순증. ①**제품 공식 명칭 = "KINTEX AI 전시·행사시스템"**(기존 "자동전시시스템/Exhibition Automation Platform" 대체 — 전시+행사/이벤트 포괄) → H1 제목·헤더 명칭 절 갱신, 정본 선언(기존 표기는 문맥 보존상 잔존) ②**`docs/analysis/coex-website.md` 신설** — 코엑스 3-사이트 IA(VISITOR `coex.co.kr`/BUSINESS `business.coex.co.kr`/CYBER 참가신청 `cybercoex.co.kr`) 분석 + 킨텍스(kintex.com) 대비표 11축 + 차용 요소. WebFetch 3사이트 + WebSearch 보조 ③**§2-2 대외 접점 3-트랙 재구성 신설** — visitor(관람객)/business(주최자·참가업체)/agency(공사·장치·협력사) 3-트랙 정의(대상·여정·진입화면·기능 매핑), 코엑스 차용요소, 공개사이트 구조(관문 랜딩 3-분기 + 트랙별 서브홈 3 + **로그인 후 트랙-스코프 홈 권고**), **라우트 체계 3안 비교→하이브리드(트랙 셸 + 기존 딥라우트 유지, 마이그레이션 비용 최소) 권고**, 기존 SCR/라우트→트랙 재배치 매핑표(신규 화면 4종=관문+서브홈3만, 나머지 재사용). 트랙은 역할의 상위 묶음(대체 아님), 내부역할 ops·admin은 트랙 외부 유지. 헤더 v3.2 정체성 라인 추가. **design.md·src 미수정(designer/frontend 후속)** — 구현 지시서 `_workspace/plan_3track.md` 별도 작성 |
| **v3.1** | 2026-07-11 | planner | **계정 통합 + 가입 트랙 분리 · 모바일 2타깃 확정(소유자 확정 2026-07-11).** 기존 스코프 전부 보존, 계정·앱 채널 전략만 정밀화(상충 시 확정안 우선·삭제 없이 개정 표기). ①**§2-1 채널 매트릭스 개정** — 모바일 열을 운영 앱(B2B)/관람객 앱(B2C)으로 분리, 가입 트랙 열 신설(업무=승인·초대+2FA 필수 vs 관람객=간편가입/게스트 예매+2FA 미강제) ②**§2-1-1 신설** — (A)계정 체계 단일 통합+가입 트랙 분리(승격 시 단일 계정 등급 상향으로 리드·비즈매칭·재방문 이력 연속성 유지), (B)모바일 코드베이스 1개(Expo `mobile/`)·배포 타깃 2개(운영 앱=스토어 미공개 사내 QR/APK, 관람객 앱=스토어 공개), 관람객 1차 접점=공개 웹(SCR-P7/P8)·앱=리텐션 채널 ③**§5B-3 인증 정밀화** — 2FA 대상을 "업무 사용자 필수·관람객 미강제"로, 승격 시 2FA 필수 전환 명시 ④**M10 갱신** — 간편가입/게스트 예매(SCR-P7)·계정 승격 연속성·게스트 예매 개인정보/병합 정책 반영. 근거: `docs/analysis/ticketing-app-benchmark.md`(게스트 예매·간편가입 업계 표준). design.md·타 문서 미수정(designer 후속 반영 필요), 시크릿 미기재 | | **v3.1** | 2026-07-11 | planner | **계정 통합 + 가입 트랙 분리 · 모바일 2타깃 확정(소유자 확정 2026-07-11).** 기존 스코프 전부 보존, 계정·앱 채널 전략만 정밀화(상충 시 확정안 우선·삭제 없이 개정 표기). ①**§2-1 채널 매트릭스 개정** — 모바일 열을 운영 앱(B2B)/관람객 앱(B2C)으로 분리, 가입 트랙 열 신설(업무=승인·초대+2FA 필수 vs 관람객=간편가입/게스트 예매+2FA 미강제) ②**§2-1-1 신설** — (A)계정 체계 단일 통합+가입 트랙 분리(승격 시 단일 계정 등급 상향으로 리드·비즈매칭·재방문 이력 연속성 유지), (B)모바일 코드베이스 1개(Expo `mobile/`)·배포 타깃 2개(운영 앱=스토어 미공개 사내 QR/APK, 관람객 앱=스토어 공개), 관람객 1차 접점=공개 웹(SCR-P7/P8)·앱=리텐션 채널 ③**§5B-3 인증 정밀화** — 2FA 대상을 "업무 사용자 필수·관람객 미강제"로, 승격 시 2FA 필수 전환 명시 ④**M10 갱신** — 간편가입/게스트 예매(SCR-P7)·계정 승격 연속성·게스트 예매 개인정보/병합 정책 반영. 근거: `docs/analysis/ticketing-app-benchmark.md`(게스트 예매·간편가입 업계 표준). design.md·타 문서 미수정(designer 후속 반영 필요), 시크릿 미기재 |

View File

@ -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 | **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 | **실데이터·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 | **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 | **10~12차 웨이브 + 리뉴얼 1차 체크포인트** — ①W10 이메일 채널(자체 Postfix 로컬 제출 — 소유자 승인·env 프로비저닝: 옥션 개설 공사업체 인앱+이메일 통지·EDM 발송(수신동의·상한500)·비번 재설정 메일·mail_log V36) ②W11 WISE 셸 정렬(소유자 지적 9건: 햄버거·단일펼침 아코디언·6그룹 IA 재구성·시스템관리 그룹·KINTEX CI 워드마크 로고·홈 차트 3종·하단 반응형·캘린더 정합·goScoped 링크수정(/login 폴백 제거·홀 파싱)) ③W12 시스템관리 기본기 8종(UIWS 40+ 대비 누락 지적 — 조직dept/company·프로그램·역할메뉴맵·인증정책·로그인이력·오류로그·시스템상태·메일/알림설정, V37·라우트 10종) ④제품 아이덴티티(지적 "뭐하는 사이트인지 모르겠다" — 공개 랜딩 제품 히어로+역할카드4+AI 파이프라인 스텝·로그인 태그라인·루트 분기 미인증→/public) ⑤홈 AI 허브(지적 "AI가 어디 있냐" — AI 도구 카드 5+자연어 퀵 입력) ⑥메뉴 IA 정합(누락 13종 편입·시스템관리 5소그룹·dead 0 — kintex-menu-recompose 절차) ⑦모바일 QR 페이지(/app-qr — WISE AppQrCode, EAS G3 승인·빌드 진행) ⑧제품명 확정 "KINTEX AI 전시·행사시스템"(4개 로케일 스윕) ⑨V38 데모 폐루프 시드(공통코드30·프로그램75·부서15·회사16·부스12·리드15·결재5·청구8·데모계정4 — 홈 todos/KPI 활성) ⑩SPA 캐시 정책(index.html no-store — 배포 반영 즉시). **신규 하네스 3종**: wise-ui 정렬(+menu-ia)·전면 리뉴얼(testdata)·벤치마킹(analyst·crawler). COEX 분석→3-트랙(visitor/business/agency) 기획 완료(PLANNING v3.2·신규 4화면 Stitch 진행) |
| 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 | **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 | **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 | **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 |

View File

@ -0,0 +1,134 @@
# 코엑스(COEX) 웹사이트 분석
> 분석일: 2026-07-12 · 대상: https://www.coex.co.kr/ (VISITOR), https://business.coex.co.kr/ (BUSINESS), cybercoex.co.kr / exhibitor.cybercoex.co.kr (참가업체 온라인신청)
> 목적: 코엑스의 방문자 유형별 정보구조(IA) 분석 → 킨텍스 자동전시시스템 **대외 접점 3-트랙(visitor/business/agency) 재구성** 근거 확보 (PLANNING §2-2)
> 방법: WebFetch(3 사이트 IA 크롤) + WebSearch 보조. 로그인·신청 상세는 접근 불가로 검색 결과로 보강.
---
## 0. 핵심 발견 — 코엑스는 방문자 유형별로 "사이트 자체"를 3개로 분리한다
코엑스 IA의 결정적 특징은 **메뉴 분기가 아니라 사이트(도메인) 분기**라는 점이다. 킨텍스가 단일 사이트(kintex.com) 안에서 정보·서식을 제공하는 것과 대비된다.
| 사이트 | 도메인 | 1차 대상 | 성격 |
|---|---|---|---|
| **VISITOR** | `www.coex.co.kr` | 관람객·일반 대중 | 방문 전/중 편의·행사 탐색 (마케팅·안내 톤) |
| **BUSINESS** | `business.coex.co.kr` | 전시주최자·임차인·(협력사) | 대관·임대·시설 규격·협력사·안전 (B2B 실무 톤) |
| **CYBER(참가신청)** | `cybercoex.co.kr` / `exhibitor.cybercoex.co.kr` | 참가업체 | 부스+부대시설 온라인 신청·결제 (트랜잭션 톤) |
이 3분할은 소유자가 지시한 **visitor / business / agency** 3-트랙과 거의 1:1로 대응한다(§4 대비표). 즉 코엑스는 이미 "누가 오느냐"로 진입점을 물리적으로 갈라 놓았고, 이는 킨텍스 시스템 재구성의 실증 레퍼런스가 된다.
---
## 1. VISITOR 사이트 (www.coex.co.kr) — 관람객·일반 대중
### 1-1. GNB 구조
- **40th Anniversary** — 창립 40주년 캠페인(브랜드 히어로)
- **행사** — 전시/컨퍼런스 일정 (진행중 30개 카드형, 티켓오픈 3개 하이라이트, Convention/Exhibition 카테고리, 일정·홈페이지·예약링크)
- **가이드(Guide)** — 방문객 편의 정보(§1-2)
- **하이라이트** — 주요 콘텐츠/기획
- **안전경영** — 경영방침·신고(공용)
- **Coex 소개** — 회사정보·채용·MICE 클러스터
- **Business** — 외부 링크(business.coex.co.kr로 이탈)
- **마곡 컨벤션센터** — 별도 시설 링크
### 1-2. 가이드(Guide) — 방문객 여정의 핵심
| 하위 메뉴 | 제공 정보 | 방문 시점 |
|---|---|---|
| **오시는 길** | 지하철(2/7/9호선)·버스 6개 정류장·승용차 7개 경로·자전거 등 교통수단별 상세 | 방문 전 |
| **주차안내** | 옥상·지하주차장 진입로, 건물별 게이트, 요금·위치 | 방문 전 |
| **편의시설** | 층별 편의 서비스(식음·쇼핑·시설) | 방문 전/중 |
| **실내 길찾기(실내 네비게이션)** | 광활한 코엑스 내부 목적지 탐색(wayfinding) | 방문 중 |
| **Coex VR** | 가상현실 사전 답사/현장 활용 | 방문 전/중 |
| **알림마당** | 공지사항·입찰공고 | 조회 |
| **고객문의** | 문의 접수 | 조회 |
| **뉴스** | 최신 소식 | 조회 |
- **접근성**: "수어 통역 서비스 제공" 명시 — 접근성을 방문안내 전면에 노출.
- **맞춤 알림**: 관심분야 선택 후 휴대폰 기반 맞춤 알림(관람객 리텐션 장치).
### 1-3. Family Site / 커머스 연계
CoexMall(쇼핑몰), KITA, WTC Seoul, KTNET, CALT, Coex VINA, COEX STORE 등 — 전시장을 넘어선 복합 MICE·리테일 생태계 링크.
---
## 2. BUSINESS 사이트 (business.coex.co.kr) — 주최자·임차인·협력사
### 2-1. GNB 구조
| 최상위 | 하위 |
|---|---|
| **전시/컨벤션** | 사업소개·주최전시회(26개 참가사 모집중)·컨벤션·해외사업(베트남·인니 등 8개) |
| **전시장** | 시설안내·임대절차및상담·임대관련자료(신청서및신고서/안내자료/도면)·임대비용·**서비스협력업체** |
| **회의실** | 시설안내·임대절차·임대관련자료·코엑스 스토어·**서비스협력업체** |
| **광장/로비** | 시설안내·이용절차·이용관련자료 |
| **베뉴서비스** | 이벤트·F&B·광고·촬영 |
| **가이드** | 주차·편의시설·고객문의·VR투어 (VISITOR 가이드 재노출) |
| **안전경영** | 경영방침·목표·실천강령·**온라인작업신고** |
| **Coex 소개** | 회사정보·연혁·조직도·채용·**MICE클러스터** |
### 2-2. 대상별 정보 배치 (BUSINESS 사이트 내부에서 다시 3자 구분)
- **전시주최자/임차인**: 전시장 > 임대절차및상담 → 시설규격(Hall A 10,368㎡·520부스 / Hall B 7,290㎡·360부스 / Hall C 10,348㎡ / Hall D 7,000명) → 임대비용 → 임대관련자료(신청서·신고서·도면). 대관 신청→서류→도면→요금 단계별 제공.
- **참가업체**: 각 전시회별 독립 신청 페이지 링크(외부 EMS = cybercoex 연동) + "회사 로그인". 즉 참가업체는 BUSINESS에서 정보만 보고, **신청은 CYBER로 이관**.
- **시공/협력업체**: 전시장·회의실 > **서비스협력업체**(공식 협력사 리스트) + 안전경영 > **온라인작업신고**(현장 작업 신고 = 킨텍스 kxwp에 해당) + 임직원 안전실천강령. → 협력사는 별도 사이트 없이 **BUSINESS 하위 + 안전경영 신고 시스템**에 얹혀 있다.
### 2-3. 시사점
코엑스는 **agency(협력사·시공)를 독립 트랙으로 완전 분리하지 않고** BUSINESS 사이트 하위(협력업체 리스트) + 안전경영(작업신고)로 배치했다. 반면 킨텍스는 독립부스 시공에 **등록 장치업체 필수(739개·14분류)**·리깅 구조계산서·규정 반려 리스크가 커서, 킨텍스 도메인에서는 agency를 **독립 트랙**으로 세우는 것이 정당화된다(§4 권고).
---
## 3. CYBER 참가신청 (cybercoex.co.kr / exhibitor.cybercoex.co.kr) — 참가업체 트랜잭션
> 직접 크롤 불가(로그인 기반) — WebSearch 결과로 재구성.
- **위치**: 서울 강남구 영동대로 513(삼성동 COEX). 참가업체 부스+부대시설 온라인 신청/지원 시스템.
- **신청 흐름**(검색 결과 기준):
1. `cybercoex.co.kr` **로그인** → 참가신청(각 전시회별 `exhibitor.cybercoex.co.kr/exhibition/{host}/{year}/{id}` 딥링크)
2. **부스비 30%** 청구서 발행일로부터 7일 내 납부
3. **부대시설 신청**(전기 등) + 각종 신고서(출입증 등) 제출
4. **잔금 70%** + 추가 비용 납부
- **성격**: 부스와 부대시설(전기·급배수·인터넷 등)을 **온라인으로 신청·결제**하는 트랜잭션 시스템. 킨텍스가 "전시회별 주최자 사무국 시스템(코리아팩 등)으로 파편화"된 것과 달리, 코엑스는 **cybercoex 공통 플랫폼**으로 참가업체 신청을 일원화했다(킨텍스 대비 코엑스의 강점).
---
## 4. 킨텍스(kintex.com) 대비표
| 축 | 코엑스(COEX) | 킨텍스(KINTEX) | 시스템 재구성 시사점 |
|---|---|---|---|
| **진입점 분리** | **사이트 3분할**(visitor/business/cyber 도메인) | 단일 사이트(kintex.com), 주최자 랜딩(index_organizer) 정도만 분기 | 킨텍스 시스템은 코엑스식 **트랙 분리**로 IA를 재편(소유자 지시) |
| **관람객(visitor)** | VISITOR 사이트 + 가이드(길찾기·주차·VR·알림) + 맞춤 알림 | 행사일정 검색·교통·주차(iparking) 수준, 관람객 전용 IA 약함 | visitor 트랙에 wayfinding·티켓·배지·맞춤알림 강화(코엑스 차용) |
| **주최자(business)** | BUSINESS 사이트, 임대절차·규격·요금·도면 단계별 | HWP 배정신청서·임대요율표 다운로드 중심 | business 트랙에 M1 자동견적·M2 배치·M6 서류 얹기 |
| **참가업체 신청** | **cybercoex 공통 플랫폼**(부스+부대시설 온라인·결제 일원화) | **전시회별 주최자 사무국 시스템으로 파편화**(공통 플랫폼 부재 = 최대 공백) | business 트랙 참가업체 e-서비스가 킨텍스 최대 차별화 여지 |
| **협력사(agency)** | BUSINESS 하위 협력업체 + 안전경영 온라인작업신고(독립 트랙 아님) | 등록업체 DB(739개·14분류) + kxwp 작업신고. **독립부스 등록업체 시공 필수** | 킨텍스는 agency를 **독립 트랙**으로(등록업체 검증·옥션·시공 — 코엑스보다 강함) |
| **협력사 강제성** | 협력사 리스트 안내 수준 | **미등록 업체 시공 엄금·자체시공 불가** | agency 트랙 = 등록업체 검증 게이트 + M15 옥션의 자연스러운 근거 |
| **온라인 작업신고** | 안전경영 > 온라인작업신고 | kxwp.kintex.com / kxfp.kintex.com | agency 트랙에서 도면제출·규정검증·작업신고 통합 |
| **시설 규격 공개** | Hall A~D ㎡·부스수·수용인원 공개 | 홀1~10 실측(63×171m·5t/㎡·600부스 등) 공개 | 양사 모두 규격 공개 → 자동 배치·견적 가능 |
| **커머스/부대사업** | CoexMall·STORE·F&B·광고·촬영 등 리테일 강함 | 부대시설(뽀로로파크·PBA·연회)·오피스·로케이션·광고 | visitor 트랙 부대서비스 안내로 흡수 가능(P2) |
| **맞춤 알림** | 관심분야 기반 휴대폰 알림 | 없음 | visitor 트랙 리텐션(M10·M12)에 반영 |
| **접근성** | 수어 통역 전면 노출 | 명시 약함 | visitor 트랙 접근성 고지 강화 |
---
## 5. 3-트랙 재구성에 차용할 코엑스 요소 (요약)
1. **진입점의 물리적 분리** — 랜딩에서 visitor/business/agency로 명확히 갈라 각 서브홈으로 보낸다(코엑스의 3-사이트 = 킨텍스의 3-트랙).
2. **visitor 트랙 = "가이드" 허브** — 오시는 길·주차·실내 길찾기·VR·편의시설·알림마당·문의를 한 곳에(코엑스 가이드 구조). 맞춤 알림·접근성 명시.
3. **business 트랙의 단계별 실무 흐름** — 임대절차→시설규격→요금→서류/도면(코엑스 전시장 메뉴) 위에 킨텍스 M1 자동견적·M2 배치·M6 서류를 얹는다.
4. **참가업체 신청 일원화** — 코엑스 cybercoex처럼 킨텍스의 파편화된 참가업체 신청을 business 트랙 공통 e-서비스로 통합(부스+유틸리티+결제).
5. **agency 트랙 = 협력사 + 작업신고 통합, 단 킨텍스는 독립 트랙으로 승격** — 코엑스는 BUSINESS 하위에 뒀지만, 킨텍스의 등록업체 필수·옥션 도메인은 독립 트랙(등록업체 검증·M15 옥션·시공 대시보드)을 정당화한다.
---
## 6. 분석한 URL 목록
- 정상 분석: `www.coex.co.kr`(VISITOR 메인), `www.coex.co.kr/guide/`(가이드), `business.coex.co.kr`(BUSINESS 메인)
- 검색 보조: `cybercoex.co.kr` / `exhibitor.cybercoex.co.kr`(참가업체 신청 — 로그인 기반, 직접 크롤 불가), `coexstore.co.kr`·`coexworld.co.kr`(부대/패밀리)
- 접근 불가: cybercoex 로그인 내부, 각 전시회 신청 상세
## 출처
- [코엑스 VISITOR](https://www.coex.co.kr/)
- [코엑스 가이드](https://www.coex.co.kr/guide/)
- [코엑스 BUSINESS](https://business.coex.co.kr/)
- [참가업체 전시부스 온라인신청 (cybercoex)](https://www.cybercoex.co.kr/)
- [exhibitor.cybercoex 참가신청 예시](https://exhibitor.cybercoex.co.kr/exhibition/100/2022/1235)
- [COEX STORE](https://www.coexstore.co.kr/)
</content>
</invoke>

View File

@ -1,6 +1,6 @@
# 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md) # 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md)
> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-12 · 버전: **v2.1.2** > 작성: 디자인 에이전트(designer) · 작성일: 2026-07-12 · 버전: **v2.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변형) 학습·정합 > 근거 문서: `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)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다. > **단일 출처(Single Source of Truth)**: 프론트 구현 토큰·컴포넌트 계약은 본 문서가 권위다. Stitch 화면(code.html/screen.png)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다.
> 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어. > 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어.
@ -413,6 +413,15 @@ An enterprise MDI (multiple document interface) application shell for "KINTEX AI
| SCR-P7 | 입장권 예매(티켓팅) | M10·M9 | 대 | ✅Stitch | | SCR-P7 | 입장권 예매(티켓팅) | M10·M9 | 대 | ✅Stitch |
| SCR-P8 | 예매 확인·취소 | M10·M9 | 대 | ✅Stitch | | SCR-P8 | 예매 확인·취소 | M10·M9 | 대 | ✅Stitch |
**웹 — 3-트랙 관문·서브홈 (§3C · SCR-T* · 공개·MDI 비적용·SEO/다국어 · v2.2 신설)**
| SCR | 화면명 | 모듈 | 역할 | 라우트 | 상태 |
|---|---|---|---|---|---|
| SCR-T0 | 트랙 관문 랜딩(3-트랙 분기) | 공통·M12 | 대 | `/` | 🔲미생성 |
| SCR-T1 | visitor 서브홈(가이드 허브) | M10·M13·M12 | 대 | `/visitor` | 🔲미생성 |
| SCR-T2 | business 서브홈(비즈니스 진입) | M1·M3·M4·M6 | 대 | `/business` | 🔲미생성 |
| SCR-T3 | agency 서브홈(등록업체·옥션) | M15·안전경영 | 협·대 | `/agency` | 🔲미생성 |
**모바일 (§4 · SCR-M*)** **모바일 (§4 · SCR-M*)**
| SCR | 화면명 | 모듈 | 역할 | 상태 | | SCR | 화면명 | 모듈 | 역할 | 상태 |
@ -1449,6 +1458,103 @@ A public "check & cancel my ticket booking" page for an exhibition, Korean marke
--- ---
## 3C. 3-트랙 관문·서브홈 (SCR-T*) — 공개 대외 접점 · MDI 비적용 · SEO/다국어
> **v2.2 신설.** 코엑스 3-사이트 IA(VISITOR·BUSINESS·CYBER)를 킨텍스 단일 도메인 위에 **visitor / business / agency 3-트랙**으로 정형화한 **얕은 트랙 진입 레이어**다(근거: `docs/PLANNING.md` §2-2 v3.2, `docs/analysis/coex-website.md`, planner 지시서 `_workspace/plan_3track.md`). 신규 화면은 **SCR-T0~T3 4종만**이며 나머지 대외 콘텐츠는 기존 SCR-P*·관람객 앱·업무 딥라우트를 **재사용**한다(회귀 0 목표).
>
> **라우트(얕은 트랙 진입 4개, 신규)**: `/`(SCR-T0 관문)·`/visitor`(SCR-T1)·`/business`(SCR-T2)·`/agency`(SCR-T3). 기존 딥 라우트(`/o/`·`/e/`·`/c/`·`/gallery/`·`/tickets/`)·MDI 셸(§2.7)·티켓 공개 라우트(SCR-P7/P8)·세션키는 **불변**.
>
> **SCR-HOME(인증 홈)과의 관계**: **관문 SCR-T0(`/`)은 미인증 전용 공개 랜딩**이다. 로그인 성공 시 흐름은 기존대로 `/home`(SCR-HOME, AppShell 셸 내부 핀 홈)으로 랜딩한다 — 관문은 SCR-HOME을 대체하지 않으며, 인증 세션이 있는 사용자가 `/`에 도달하면 트랙 셸이 `/home`으로 우선 리다이렉트(딥링크·SEO 크롤러·게스트 티켓 조회는 예외로 공개 유지). 즉 SCR-T0=대외 유입 관문, SCR-HOME=업무 세션 홈으로 역할이 분리된다.
>
> **디자인 토큰**: §1 라이트 테마·`primary-600 #0066B3`·Pretendard·8px 카드·WCAG AA 준수. **신규 토큰 0.** AI 생성 이미지(나노바나나)는 워터마크 "AI 생성 예상 이미지" 상시 노출 규칙 유지. 이모지 금지·선(stroke) SVG 아이콘.
### 3C-0. 공개 셸(PublicShell) + 3-트랙 스위처 (전 SCR-T*·SCR-P* 공통 크롬)
- **재사용 전제**: SCR-P*가 쓰는 **PublicShell**(전통 페이지 헤더 GNB·푸터·SSR/CDN·MDI 비적용)을 그대로 재사용하되, **상단에 3-트랙 전환 탭(TrackSwitcher)을 추가**한다. 관문(SCR-T0)은 트랙 미선택 상태, 서브홈(T1~T3)은 해당 트랙 탭 활성.
- **TrackSwitcher 사양**(헤더 최상단 유틸 행 또는 GNB 좌측): 세그먼트 3탭 `관람 · 비즈니스 · 협력사`(라벨=`track.shell.tab.visitor|business|agency`), 각 탭 클릭 시 `/visitor`·`/business`·`/agency` 이동. 좌측에 **KINTEX AI 전시·행사시스템** 워드마크(클릭 시 `/` 관문 복귀). 우측 유틸: 언어 전환(한/영/중/일 `track.shell.lang.*`)·`로그인`(→ SCR-01)·인증 세션 시 `내 홈`(→ `/home`).
- **활성 표시**: 활성 트랙은 `primary-600` 밑줄+굵게, 색 외 `aria-current="page"` 병기(색만으로 전달 금지). 모바일(390px)은 탭을 상단 가로 세그먼트로 유지(스크롤 없이 3탭 균등폭).
- **테넌트 정합(§1A-3)**: 셸은 서브도메인 테넌트 컨텍스트 하에서 해당 테넌트 콘텐츠만 노출(워드마크·브랜딩 분기). 트랙 셸은 `tenant_id` 격리 위에서 동작.
- **접근성**: TrackSwitcher는 `role="tablist"`(수평)·로빙 탭인덱스·포커스 링 `primary-600` 2px. 푸터 `role="contentinfo"`. **i18n 네임스페이스 `track.shell.*`**.
### SCR-T0 트랙 관문 랜딩 (3-트랙 분기 관문) [공통 · 공개 · 루트 `/`]
- **대상**: 일반 대중·대외 유입 전원(미인증) · **모듈/P**: 공통 대외 접점(M12 일부 재사용) · P1 · **진입 경로**: 루트 도메인 `/` 직접 진입·검색·광고·QR. 인증 세션 시 `/home` 우선 리다이렉트(위 SCR-HOME 관계 참조)
- **레이아웃**(위→아래, PublicShell·풀블리드 마케팅 밀도):
1. **PublicShell 헤더 + TrackSwitcher**(관문=트랙 미선택, 워드마크·언어·로그인)
2. **제품 히어로**: 풀폭 히어로 — 나노바나나 전시 전경 이미지(워터마크 "AI 생성 예상 이미지") 배경 + 대형 헤드라인 "**KINTEX AI 전시·행사시스템**" + 서브카피(전시 기획→시공→관람 전 과정 AI 자동화) + 보조 CTA "관람 사전등록"·"참가 문의". **관람객 시각 1순위**(코엑스 VISITOR 메인 톤)
3. **3-트랙 대형 분기 카드**(3열 균등, 화면 중앙 히어로 하단 정박): ① `관람객` — "행사 일정·사전등록·오시는 길", CTA "관람 트랙 →"(`/visitor`) ② `주최·참가(비즈니스)` — "대관·자동견적·부스 신청·시공 시안", CTA "비즈니스 트랙 →"(`/business`) ③ `공사·협력사(에이전시)` — "등록업체·공사 옥션·작업신고", CTA "협력사 트랙 →"(`/agency`). 각 카드=아이콘(선 SVG)·대상 라벨·1줄 가치제안·대표 액션. 관람 카드를 시각적으로 약간 크게(1순위)
4. **행사 하이라이트 레일**(기존 P1 행사 그리드 일부 재사용): 진행 중·예정 행사 가로 스크롤 포스터 카드 6~8장(포스터·행사명·기간·홀·D-day·"사전등록")
5. **PublicShell 푸터**(회사·교통 GTX-A 킨텍스역·언어·정책)
- **구성요소**: HeroBanner(AI 워터마크)·TrackCard×3·EventRail(포스터 카드)·TrackSwitcher·LanguageSelector·DdayChip·StatusBadge(행사 상태)
- **데이터 원천**: 히어로 배경 = **SCR-01 login-slides 자산/API 재사용**(나노바나나 전경) · 3-트랙 카드 = 정적 구성(트랙 메타) · 행사 하이라이트 = `/api/public/events`(SCR-P1 그리드 자산 재사용, event 테이블 poster_url) → 실 API 재사용, 트랙 카드만 신규 정적
- **상태**: 로딩(행사 레일 스켈레톤) · 빈(공개 행사 없음 → 히어로+트랙 카드만, 레일 "예정된 전시가 없습니다") · 에러(행사 API 실패 시 트랙 카드는 정상 노출·레일만 재시도) · 인증 세션 감지 시 `/home` 리다이렉트
- **접근성**: 트랙 카드는 카드 전체 클릭 가능+명시 링크 텍스트, 히어로 배경 이미지 위 헤드라인 대비 확보(오버레이 딤), 색 외 라벨 병기, WCAG AA. **i18n 네임스페이스 `track.gateway.*`**(+ 공유 `track.shell.*`)
- **Stitch 프롬프트**:
```
A public landing "track gateway" home page for the product "KINTEX AI 전시·행사시스템" (an AI exhibition & event operations platform), Korean, desktop 1440px, light theme, primary blue #0066B3, purple #6D4AFF for AI accents, clean modern marketing design (not an MDI app shell). All UI text in Korean; use line (stroke) SVG icons only, NO emoji. Top: a public header with a "KINTEX AI 전시·행사시스템" wordmark on the left, a 3-tab track switcher "관람 · 비즈니스 · 협력사" (none active on this gateway), and right utilities "언어(한/영/중/일) · 로그인". Full-bleed hero with a photorealistic exhibition-hall render as background (with a faint watermark "AI 생성 예상 이미지" and a dim overlay for text contrast), a large headline "KINTEX AI 전시·행사시스템", a subheading "전시 기획부터 시공·관람까지 전 과정을 AI로 자동화", and two CTAs "관람 사전등록" and "참가 문의". Directly below the hero: three large branching track cards in one row — card 1 "관람객" (slightly larger, primary) with a stroke icon, text "행사 일정 · 사전등록 · 오시는 길" and a button "관람 트랙 →"; card 2 "주최 · 참가 (비즈니스)" with text "대관 · 자동견적 · 부스 신청 · 시공 시안" and a button "비즈니스 트랙 →"; card 3 "공사 · 협력사 (에이전시)" with text "등록업체 · 공사 옥션 · 작업신고" and a button "협력사 트랙 →". Below: a horizontal scrolling rail of event poster cards (poster, title, dates, hall, a "D-25" day chip, "사전등록" button) titled "진행 중 · 예정 전시". Footer with company info and "오시는 길 · GTX-A 킨텍스역". Spacious, multilingual, conversion-oriented gateway aesthetic; rounded 8px cards, WCAG AA contrast.
```
### SCR-T1 visitor 서브홈 (관람객 공개 홈 · 가이드 허브) [visitor · 공개 · `/visitor`]
- **대상**: 일반 대중·관람객(게스트+회원) · **모듈/P**: M10(사전등록·티켓)·M13(실내 길찾기)·M12(공지) · P1 · **진입 경로**: 관문 SCR-T0 "관람 트랙" · TrackSwitcher "관람" · 직접 `/visitor`
- **레이아웃**(위→아래, PublicShell·SCR-P1 확장):
1. **PublicShell 헤더 + TrackSwitcher**("관람" 활성)
2. **관람 히어로 + 주 CTA**: 관람객 톤 히어로 + "사전등록"·"입장권 예매"(→ SCR-P4·P7) 이중 CTA + 맞춤 알림 유도("관심 분야 알림 받기")
3. **행사 일정 그리드**(SCR-P1 행사 카드 재사용): 진행 중·예정 행사 카드 그리드(포스터·행사명·기간·홀·상태 배지·"사전등록")+전시장 세그먼트 필터(전체/홀, 동적 옵션)
4. **방문 가이드 허브**(코엑스 가이드 차용 — 2×3 카드): ① 교통(GTX-A 킨텍스역 도보 3분·광역버스) ② 주차(iparking 실시간·요금·게이트) ③ **실내 길찾기**(M13 wayfinding 딥링크) ④ 편의시설(식음·수유실·물품보관·ATM) ⑤ 접근성·편의(휠체어·수어통역 고지) ⑥ 고객문의(FAQ·연락처). 각 카드 선 SVG 아이콘+1줄 설명+링크
5. **공지·알림마당**: 최근 공지 5건 리스트(§5B notice 재사용)+더보기
6. **PublicShell 푸터**
- **구성요소**: HeroBanner·EventGrid(P1 카드)·GuideHubCard×6·NoticeList·LanguageSelector·CTA(사전등록·티켓·알림)
- **데이터 원천**: 행사 그리드 = `/api/public/events`(SCR-P1 자산 재사용) · 사전등록/티켓 CTA = SCR-P4/P7 딥링크 · 실내 길찾기 = M13 wayfinding(SCR-M9 계열) 딥링크 · 공지 = §5B notice `/work/notice` 공개 서브셋 → 실 API 재사용, 가이드 정적 콘텐츠(교통·주차·편의 안내)는 CMS/정적. **SEO·다국어 적용, MDI 비적용**
- **상태**: 로딩(행사·공지 스켈레톤) · 빈(행사 없음 "예정된 전시가 없습니다"·공지 없음) · 에러(영역별 재시도, 가이드 허브는 정적이라 상시 노출)
- **접근성**: 가이드 카드 링크 텍스트 명시, 접근성 안내 카드 자체가 WCAG AA·수어/휠체어 정보 제공, 색 외 라벨 병기. **i18n 네임스페이스 `track.visitor.*`**(+ 공유 `track.shell.*`)
- **Stitch 프롬프트**:
```
A public visitor sub-home / visit-guide hub page for the product "KINTEX AI 전시·행사시스템", visitor track, Korean, desktop 1440px, light theme, primary blue #0066B3, clean marketing design (not MDI). All UI text in Korean; line (stroke) SVG icons only, NO emoji. Top: public header with "KINTEX AI 전시·행사시스템" wordmark, a 3-tab track switcher "관람 · 비즈니스 · 협력사" with "관람" active (underlined, aria-current), and right utilities "언어 · 로그인". A visitor-toned hero with headline "전시 관람, 여기서 시작하세요", dual CTAs "사전등록" and "입장권 예매", and a subtle "관심 분야 알림 받기" prompt. Below: an event schedule grid of cards (poster, title, dates "2026.09.0509.08", hall, a status badge, "사전등록" button) with a venue segment filter "전체 · 제1전시장 · 제2전시장". Then a "방문 가이드" hub section as a 2×3 grid of cards, each with a stroke icon, title and one-line text and a link: "교통 · GTX-A 킨텍스역 도보 3분", "주차 · 실시간 주차 안내", "실내 길찾기", "편의시설 · 식음·수유실·물품보관", "접근성 · 휠체어·수어통역 안내", "고객문의 · FAQ·연락처". Then a "공지사항" list of 5 recent notices with a "더보기" link. Footer. Helpful, welcoming, accessible public visitor-guide aesthetic; rounded 8px cards, WCAG AA contrast.
```
### SCR-T2 business 서브홈 (비즈니스 안내·진입 홈) [business · 공개 · `/business`]
- **대상**: 전시주최자·참가업체(미인증 대외) · **모듈/P**: M1(대관·자동견적)·M3/M4(부스 신청·유틸리티)·M6(서류) 안내 · P1 · **진입 경로**: 관문 SCR-T0 "비즈니스 트랙" · TrackSwitcher "비즈니스" · 직접 `/business`
- **레이아웃**(위→아래, PublicShell·SCR-P6 확장·코엑스 BUSINESS+CYBER 차용):
1. **PublicShell 헤더 + TrackSwitcher**("비즈니스" 활성)
2. **비즈니스 히어로**: B2B 실무 톤 헤드라인 "전시 개최·참가, 온라인 신청 일원화" + "로그인"·"신청/문의" CTA
3. **2-분기 가치 제안 카드**(2열 대형): ① **주최자** — 홀 임대 절차(문의→가견적→계약→배정) + "자동견적(M1)"·"홀 배정·플로어플랜(M2)" 안내 + 요금/시설 규격 개요 + CTA "대관 문의·로그인" ② **참가업체** — 부스 신청·서류·시공 시안(부스 유형·유틸리티 신청 M4·규정 사전검증·나노바나나 시공 시안 M5) + CTA "참가 신청·로그인". **온라인 신청 일원화 강조**(코엑스 CYBER 차용 — "모든 신청·결제를 온라인에서")
4. **임대 절차 흐름 스텝**(코엑스 BUSINESS 임대절차): 가로 스텝 인디케이터 "문의 → 가견적 → 계약 → 홀 배정 → 부스 신청 → 시공·검수"(각 스텝 1줄 설명, 해당 모듈 배지)
5. **요금·서류 안내**: 부스 유형·요율 개요(SCR-P6 재사용) + 주요 서류(배정신청서·방염·구조계산서) 다운로드/안내
6. **로그인/문의 CTA 밴드**: "이미 계정이 있으신가요? 로그인"(→ SCR-01 → 역할 판정 → 주최자 SCR-02 / 참가 SCR-05) + 문의 폼(SCR-P6 재사용)
7. **PublicShell 푸터**
- **구성요소**: HeroBanner·ValuePropCard×2(주최자·참가)·ProcessStepper·RateOverview·DocList·InquiryForm(P6)·LoginCTA·TrackSwitcher
- **데이터 원천**: 가치 제안·절차·요금 = 정적/CMS 안내(M1·M2·M4·M5·M6 소개) · 부스 유형·요율 = SCR-P6 자산 재사용 · 문의 폼 = SCR-P6 재사용 · 로그인 CTA = SCR-01 딥링크(성공 시 역할 홈 SCR-02/05) → 신규 트랜잭션 없음(안내+진입). **SEO·다국어, MDI 비적용**
- **상태**: 로딩(요금·안내 스켈레톤) · 빈(공지성 콘텐츠 없음 시 기본 안내 유지) · 에러(문의 전송 실패 인라인 재시도) · 문의 전송 완료 토스트
- **접근성**: 2-분기 카드 각 CTA 명시 링크, 절차 스텝 `ol`+스크린리더 순서, 색 외 배지 텍스트, WCAG AA. **i18n 네임스페이스 `track.business.*`**(+ 공유 `track.shell.*`)
- **Stitch 프롬프트**:
```
A public business sub-home / entry page for the product "KINTEX AI 전시·행사시스템", business track (for exhibition organizers and exhibitors), Korean, desktop 1440px, light theme, primary blue #0066B3, purple #6D4AFF for AI, B2B professional tone (not MDI). All UI text in Korean; line (stroke) SVG icons only, NO emoji. Top: public header with "KINTEX AI 전시·행사시스템" wordmark, a 3-tab track switcher "관람 · 비즈니스 · 협력사" with "비즈니스" active, right utilities "언어 · 로그인". Hero headline "전시 개최·참가, 온라인 신청 일원화" with CTAs "신청·문의" and "로그인". Below, two large value-proposition cards side by side: left "주최자 · 홀 임대" listing "대관 절차 · 자동견적(AI) · 홀 배정·플로어플랜 · 요금/시설 규격" with a button "대관 문의·로그인"; right "참가업체 · 부스 신청" listing "부스 신청 · 유틸리티 신청 · 규정 사전검증 · AI 시공 시안" with a button "참가 신청·로그인" (AI items marked with a small purple "AI" chip). Then a horizontal process stepper "문의 → 가견적 → 계약 → 홀 배정 → 부스 신청 → 시공·검수" with a one-line caption per step. Then a "요금·서류 안내" section with booth-type rate cards (조립부스 / 독립부스 / 프리미엄) and a document list (배정신청서 · 방염 확인서 · 구조계산서). A login/inquiry CTA band "이미 계정이 있으신가요? 로그인" plus a compact inquiry form (회사명, 담당자, 연락처, 희망 홀, 문의 내용, "문의 보내기"). Footer. Trustworthy B2B lead-generation and online-application aesthetic; rounded 8px cards, tabular numerals for rates, WCAG AA.
```
### SCR-T3 agency 서브홈 (에이전시 안내·등록업체 진입 홈) [agency · 공개 · `/agency`]
- **대상**: 공사·장치·협력업체(미인증 대외)·등록업체 · **모듈/P**: M15(공사 옥션·역경매)·안전경영(작업신고)·등록업체 검증 · P1 · **진입 경로**: 관문 SCR-T0 "협력사 트랙" · TrackSwitcher "협력사" · 직접 `/agency`
- **레이아웃**(위→아래, PublicShell·코엑스 서비스협력업체+안전경영 차용·킨텍스 옥션 차별점 강조):
1. **PublicShell 헤더 + TrackSwitcher**("협력사" 활성)
2. **에이전시 히어로**: "킨텍스 공식 등록업체로 전시 시공에 참여하세요" + "등록 안내"·"등록업체 로그인" CTA. **킨텍스 차별점(옥션) 강조** 서브카피
3. **등록업체 안내 카드**: 등록업체 현황(739개사·14개 분류) + **미등록 업체 시공 엄금·자체시공 불가 필수 고지**(warning 톤 안내) + 등록 절차(신청→자격심사→등록→수주 자격) + CTA "등록 신청"
4. **진행 중 공사 옥션 공고 리스트(M15 · 역경매)**: 카드/행 리스트 — 옥션명·대상 부스/행사·예산 범위·마감 D-day(DdayChip)·응찰 현황(참여 업체 수)·상태 배지. 각 행 "상세·응찰"(로그인 유도). **킨텍스 차별점(역경매 옥션) 히어로 다음으로 크게** 배치
5. **작업신고·규정 안내**(코엑스 안전경영 온라인작업신고): 현장 작업신고 절차·안전 규정(금지작업·소음·리깅 구조계산서)·필수 서류(방염·보험) 안내 카드 + "작업신고 하기"(로그인)
6. **등록업체 로그인 CTA 밴드**: "등록업체 로그인"(→ SCR-01 → 장치업체 판정 → 수주/응찰 대시보드 SCR-27/28)
7. **PublicShell 푸터**
- **구성요소**: HeroBanner·RegistryInfoCard(고지)·AuctionList(M15 공고 카드·DdayChip·StatusBadge)·SafetyGuideCard·LoginCTA·TrackSwitcher
- **데이터 원천**: 등록업체 현황·절차 = 정적/CMS 안내(739·14분류·등록 게이트) · **진행 중 옥션 = M15 공개 서브셋**(`/api/public/auctions` 또는 옥션 목록 SCR-24 공개 읽기 서브셋 — 응찰은 로그인 후 SCR-27/28) · 작업신고·규정 = 정적 안내 · 로그인 CTA = SCR-01 딥링크(성공 시 SCR-27/28) → 옥션 리스트만 실 API(공개 읽기), 나머지 안내 정적. **SEO·다국어, MDI 비적용**
- **상태**: 로딩(옥션 리스트 스켈레톤) · 빈(진행 중 옥션 없음 "현재 진행 중인 공사 옥션이 없습니다") · 에러(옥션 API 실패 재시도, 등록·규정 안내는 정적 상시 노출) · 마감 임박 옥션 `warning` D-day 강조
- **접근성**: 필수 등록 고지는 색 외 텍스트로 명확히("미등록 업체는 시공에 참여할 수 없습니다"), 옥션 D-day 색+텍스트 병기, 로그인 CTA 명시, WCAG AA. **i18n 네임스페이스 `track.agency.*`**(+ 공유 `track.shell.*`)
- **Stitch 프롬프트**:
```
A public agency (contractor / booth-construction partner) sub-home page for the product "KINTEX AI 전시·행사시스템", agency track, Korean, desktop 1440px, light theme, primary blue #0066B3, warning orange #B45309, clean B2B tone (not MDI). All UI text in Korean; line (stroke) SVG icons only, NO emoji. Top: public header with "KINTEX AI 전시·행사시스템" wordmark, a 3-tab track switcher "관람 · 비즈니스 · 협력사" with "협력사" active, right utilities "언어 · 등록업체 로그인". Hero headline "킨텍스 공식 등록업체로 전시 시공에 참여하세요" with a subheading emphasizing the differentiator "공사 옥션(역경매)로 투명하게 수주", and CTAs "등록 안내" and "등록업체 로그인". Below: a "등록업체 안내" card showing "등록업체 739개사 · 14개 분류" with a prominent warning-toned notice "미등록 업체는 전시 시공에 참여할 수 없습니다 (자체시공 불가)" and a registration process "신청 → 자격심사 → 등록 → 수주 자격", button "등록 신청". Then, prominently, a "진행 중 공사 옥션 (역경매)" list of auction announcement rows/cards — each with auction name, target booth/event, budget range, a red/orange "D-3" day chip, bidder count "응찰 4개사", a status badge, and a "상세·응찰" link (login-gated). Then a "작업신고 · 안전 규정" section with cards for on-site work report procedure, prohibited works / noise limit / rigging structural calc, and required documents (방염·보험), with a button "작업신고 하기". A bottom login CTA band "등록업체 로그인". Footer. Professional partner-portal aesthetic highlighting the reverse-auction differentiator; rounded 8px cards, tabular numerals, WCAG AA contrast.
```
---
## 4. 모바일 대응 (390px) ## 4. 모바일 대응 (390px)
모바일은 **조회·승인·현장** 시나리오 전용(PLANNING.md §8). 캔버스 편집(SCR-03, 07)과 스펙 편집(SCR-06 좌패널)은 모바일 미제공 — 뷰어+승인 액션만. 아래 두 화면은 **모바일 전용 신규 화면**, 나머지는 반응형 축소. 모바일은 **조회·승인·현장** 시나리오 전용(PLANNING.md §8). 캔버스 편집(SCR-03, 07)과 스펙 편집(SCR-06 좌패널)은 모바일 미제공 — 뷰어+승인 액션만. 아래 두 화면은 **모바일 전용 신규 화면**, 나머지는 반응형 축소.
@ -1666,6 +1772,7 @@ A mobile "my ticket wallet" screen for an exhibition visitor, 390px width, light
| 버전 | 일자 | 작성자 | 내용 | | 버전 | 일자 | 작성자 | 내용 |
|---|---|---|---| |---|---|---|---|
| **v2.2** | 2026-07-12 | designer | **3-트랙(visitor/business/agency) 대외 접점 재편 — 신규 4화면 SCR-T0~T3 순증(planner 지시서 `_workspace/plan_3track.md`·PLANNING v3.2 §2-2·`docs/analysis/coex-website.md` 근거).** 코엑스 3-사이트 IA(VISITOR·BUSINESS·CYBER)를 킨텍스 단일 도메인 위 3-트랙 얕은 진입 레이어로 정형화. ①**§3C 신설** — 3C-0 공개 셸(PublicShell) 재사용+상단 3-트랙 스위처(TrackSwitcher, `role=tablist`·`관람/비즈니스/협력사`·워드마크 `/` 복귀·언어·로그인·`내 홈`) 사양, SCR-T0 관문 랜딩(루트 `/`·제품 히어로 "KINTEX AI 전시·행사시스템"+3-트랙 대형 분기 카드+행사 하이라이트 레일, 관람 1순위), SCR-T1 visitor 서브홈(`/visitor`·행사 그리드+사전등록/티켓 CTA+2×3 방문 가이드 허브(교통 GTX-A·주차·M13 실내 길찾기·편의·접근성·문의)+공지), SCR-T2 business 서브홈(`/business`·주최자/참가 2-분기 가치제안+임대 절차 스텝+요금·서류+로그인/문의 CTA→SCR-01→SCR-02/05, 온라인 신청 일원화 강조), SCR-T3 agency 서브홈(`/agency`·등록업체 안내 739·14분류·미등록 시공 엄금 고지+진행 중 M15 공사 옥션 역경매 리스트+작업신고·규정+등록업체 로그인→SCR-27/28, 옥션 차별점 강조) — 각 화면 레이아웃·구성요소·데이터 원천(기존 API 재사용 매핑: login-slides·`/api/public/events`·notice·SCR-P6·M15 공개 서브셋)·상태(로딩/빈/에러/degraded)·접근성·i18n 네임스페이스(`track.gateway/visitor/business/agency/shell.*`)+영어 Stitch 프롬프트(Precision Enterprise AI·1440·NO emoji·선 SVG·WCAG AA). ②**SCR-HOME 관계 명시**: 관문 SCR-T0(`/`)=미인증 전용 공개 랜딩, 인증 세션 시 `/home`(SCR-HOME) 우선 리다이렉트(딥링크·SEO·게스트 티켓 조회 예외). ③**§2.8-2 인벤토리**에 SCR-T* 표 추가(집계 85→**89화면**, 미생성 2→6: SCR-HOME·M3+T0~T3). **경계 준수**: 기존 SCR·라우트·MDI 셸(§2.7)·티켓 공개 라우트(SCR-P7/P8)·세션키 불변, 신규 라우트는 얕은 트랙 진입 4개만, ops/admin 트랙 외부 유지, §1A 멀티테넌시 정합. §1 토큰 권위·**신규 토큰 0**, 기존 화면 스펙·PLANNING.md·src 무수정 |
| **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.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.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.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 무수정 |

View File

@ -2,39 +2,53 @@
"expo": { "expo": {
"name": "KINTEX AI 전시관리", "name": "KINTEX AI 전시관리",
"slug": "kintex", "slug": "kintex",
"owner": "zioinfo",
"scheme": "kintex", "scheme": "kintex",
"version": "0.1.0", "version": "0.1.0",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",
"newArchEnabled": false, "icon": "./assets/icon.png",
"splash": { "splash": {
"image": "./assets/splash.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#004C86" "backgroundColor": "#004C86"
}, },
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": [
"**/*"
],
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "kr.co.zioinfo.kintex" "bundleIdentifier": "kr.co.zioinfo.kintex"
}, },
"android": { "android": {
"package": "kr.co.zioinfo.kintex", "package": "kr.co.zioinfo.kintex",
"versionCode": 1,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0066B3" "backgroundColor": "#0066B3"
} }
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
"output": "single" "output": "single",
"favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-secure-store" "expo-secure-store",
"expo-font"
], ],
"experiments": { "experiments": {
"typedRoutes": false "typedRoutes": false
}, },
"extra": { "extra": {
"apiBase": "https://kintex.zioinfo.co.kr" "apiBase": "https://kintex.zioinfo.co.kr",
"router": {
"origin": false
},
"eas": {
"projectId": "80b0a6ae-a2f6-4a25-8a9e-daa666543647"
}
} }
} }
} }

27
mobile/eas.json Normal file
View File

@ -0,0 +1,27 @@
{
"cli": {
"version": ">= 10.0.0",
"appVersionSource": "local"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": { "buildType": "apk" }
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {}
}
}

15339
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,9 @@ dependencies {
// --- Redis (RenderJob· · ) --- // --- Redis (RenderJob· · ) ---
implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// --- ( Postfix SMTP ·EDM· ) ---
implementation 'org.springframework.boot:spring-boot-starter-mail'
// --- JWT ( RBAC ) --- // --- JWT ( RBAC ) ---
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"

View File

@ -231,6 +231,38 @@ public interface AuctionMapper {
""") """)
List<Map<String, Object>> listMyAwardedBooths(@Param("companyId") String companyId); List<Map<String, Object>> listMyAwardedBooths(@Param("companyId") String companyId);
// 옥션 개설 통지 대상(§5B 인앱 + 이메일)
/** 행사명(통지 본문용). */
@Select("SELECT name FROM event WHERE id = #{eventId}")
String findEventName(@Param("eventId") String eventId);
/**
* 초대 옥션의 초대 업체 소속 CONTRACTOR 사용자(통지 대상). email 발송 경로 한정 원문.
* 별칭 쌍따옴표 필수(카멜케이스 보존).
*/
@Select("""
SELECT DISTINCT u.id AS "userId", u.email AS "email", u.display_name AS "displayName"
FROM auction_invite ai
JOIN event_member em ON em.company_id = ai.company_id
AND em.event_id = (SELECT event_id FROM auction WHERE id = #{auctionId})
JOIN app_user u ON u.id = em.user_id
WHERE ai.auction_id = #{auctionId}
AND em.role_code = 'CONTRACTOR'
AND u.status = 'ACTIVE'
""")
List<Map<String, Object>> listInvitedContractorUsers(@Param("auctionId") String auctionId);
/** 공개 옥션(초대 0) — 행사 전체 CONTRACTOR 사용자(통지 대상). */
@Select("""
SELECT DISTINCT u.id AS "userId", u.email AS "email", u.display_name AS "displayName"
FROM event_member em
JOIN app_user u ON u.id = em.user_id
WHERE em.event_id = #{eventId}
AND em.role_code = 'CONTRACTOR'
AND u.status = 'ACTIVE'
""")
List<Map<String, Object>> listEventContractorUsers(@Param("eventId") String eventId);
// F035 BOQ 물량 집계(M2 booth/layout·M4 utility_order 읽기 전용) // F035 BOQ 물량 집계(M2 booth/layout·M4 utility_order 읽기 전용)
/** /**
* 부스 폴리곤 면적·둘레·부스수·최대높이 집계. boothNo null 이면 행사 전체 부스, 있으면 해당 부스코드만. * 부스 폴리곤 면적·둘레·부스수·최대높이 집계. boothNo null 이면 행사 전체 부스, 있으면 해당 부스코드만.

View File

@ -30,13 +30,24 @@ public class AuctionService {
private static final int MAX_LIMIT = 500; private static final int MAX_LIMIT = 500;
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuctionService.class);
private final AuctionMapper mapper; private final AuctionMapper mapper;
private final com.zioinfo.kintex.settlement.SettlementService settlementService; private final com.zioinfo.kintex.settlement.SettlementService settlementService;
private final com.zioinfo.kintex.work.notification.NotificationService notificationService;
private final com.zioinfo.kintex.mail.MailService mailService;
private final com.zioinfo.kintex.mail.MailProperties mailProperties;
public AuctionService(AuctionMapper mapper, public AuctionService(AuctionMapper mapper,
com.zioinfo.kintex.settlement.SettlementService settlementService) { com.zioinfo.kintex.settlement.SettlementService settlementService,
com.zioinfo.kintex.work.notification.NotificationService notificationService,
com.zioinfo.kintex.mail.MailService mailService,
com.zioinfo.kintex.mail.MailProperties mailProperties) {
this.mapper = mapper; this.mapper = mapper;
this.settlementService = settlementService; this.settlementService = settlementService;
this.notificationService = notificationService;
this.mailService = mailService;
this.mailProperties = mailProperties;
} }
// 목록(SCR-26) // 목록(SCR-26)
@ -376,9 +387,73 @@ public class AuctionService {
mapper.insertInvite(inv); mapper.insertInvite(inv);
} }
} }
// 개설 통지 대상 공사업체(초대 있으면 초대 업체, 없으면 행사 CONTRACTOR 전체)에게
// §5B 인앱 알림 이메일(MAIL_ENABLED=false면 인앱만). 통지 실패는 개설을 되돌리지 않는다.
notifyAuctionOpened(id, req.eventId(), req.title(), req.deadline());
return toSummary(mapper.findAuction(id)); return toSummary(mapper.findAuction(id));
} }
/** 개설 통지 발행(인앱 + 이메일). 실패 격리 — 알림 오류가 개설 트랜잭션을 깨지 않는다. */
private void notifyAuctionOpened(String auctionId, String eventId, String title, String deadline) {
try {
List<Map<String, Object>> targets = mapper.countInvites(auctionId) > 0
? mapper.listInvitedContractorUsers(auctionId)
: mapper.listEventContractorUsers(eventId);
if (targets == null || targets.isEmpty()) {
return;
}
String eventName = mapper.findEventName(eventId);
if (eventName == null || eventName.isBlank()) {
eventName = "행사";
}
String link = "/auctions/" + auctionId;
String inAppMsg = "응찰 마감 " + shortDeadline(deadline) + " · " + eventName;
String mailLink = mailProperties.getWebBaseUrl() + link;
String subject = "[KINTEX] 공사 옥션 개설 안내 · " + title;
String html = com.zioinfo.kintex.mail.MailTemplates.auctionOpened(
eventName, title, shortDeadline(deadline), mailLink);
for (Map<String, Object> u : targets) {
String userId = str(u.get("userId"));
if (userId == null) {
continue;
}
// 인앱 알림(발송 채널 단일화 §7-6)
try {
notificationService.notify(userId, eventId, "MESSAGE",
"새 공사 옥션 개설: " + title, inAppMsg, link);
} catch (RuntimeException e) {
log.warn("옥션 인앱 통지 실패(무시): auction={} reason={}", auctionId, e.getClass().getSimpleName());
}
// 이메일(@Async·실패 격리 MAIL_ENABLED=false면 내부에서 스킵)
String email = str(u.get("email"));
if (email != null && !email.isBlank()) {
mailService.send("AUCTION_OPEN", auctionId, email, subject, html);
}
}
} catch (RuntimeException e) {
log.warn("옥션 개설 통지 처리 실패(개설은 정상): auction={} reason={}",
auctionId, e.getClass().getSimpleName());
}
}
/** deadline(ISO 또는 원문)에서 날짜/시각 앞부분만 짧게 — 통지 표기용(파싱 실패 시 원문). */
private static String shortDeadline(String deadline) {
if (deadline == null || deadline.isBlank()) {
return "-";
}
String s = deadline.trim();
int t = s.indexOf('T');
if (t > 0) {
String date = s.substring(0, t);
String time = s.length() >= t + 6 ? s.substring(t + 1, t + 6) : "";
return time.isEmpty() ? date : date + " " + time;
}
return s.length() > 16 ? s.substring(0, 16) : s;
}
// 업체 포털 대시보드(SCR-38) // 업체 포털 대시보드(SCR-38)
public ContractorDashboard contractorDashboard(KintexPrincipal principal) { public ContractorDashboard contractorDashboard(KintexPrincipal principal) {
Map<String, Object> co = mapper.findUserCompany(principal.userId()); Map<String, Object> co = mapper.findUserCompany(principal.userId());

View File

@ -41,10 +41,16 @@ public class PublicAuthService {
private final PublicAuthMapper mapper; private final PublicAuthMapper mapper;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final com.zioinfo.kintex.mail.MailService mailService;
private final com.zioinfo.kintex.mail.MailProperties mailProperties;
public PublicAuthService(PublicAuthMapper mapper, PasswordEncoder passwordEncoder) { public PublicAuthService(PublicAuthMapper mapper, PasswordEncoder passwordEncoder,
com.zioinfo.kintex.mail.MailService mailService,
com.zioinfo.kintex.mail.MailProperties mailProperties) {
this.mapper = mapper; this.mapper = mapper;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.mailService = mailService;
this.mailProperties = mailProperties;
} }
/** 회원가입 — 이메일 중복 시 409, 성공 시 민감정보 제외 요약 반환. */ /** 회원가입 — 이메일 중복 시 409, 성공 시 민감정보 제외 요약 반환. */
@ -95,9 +101,14 @@ public class PublicAuthService {
String codeHash = passwordEncoder.encode(code); String codeHash = passwordEncoder.encode(code);
mapper.insertResetCode(email, codeHash, mapper.insertResetCode(email, codeHash,
OffsetDateTime.now().plusMinutes(RESET_TTL_MINUTES)); OffsetDateTime.now().plusMinutes(RESET_TTL_MINUTES));
// 이메일 발송 인프라 부재 dev 로깅. 코드 값은 마스킹(원문·해시 노출 금지, 계약 §0-3). // 코드 값은 마스킹 로그(원문·해시 노출 금지, 계약 §0-3).
log.info("비밀번호 재설정 코드 발급(email={}, code={}, ttl={}m)", log.info("비밀번호 재설정 코드 발급(email={}, code={}, ttl={}m)",
maskEmail(email), maskCode(code), RESET_TTL_MINUTES); maskEmail(email), maskCode(code), RESET_TTL_MINUTES);
// 재설정 코드 이메일 발송(@Async·실패 격리 MAIL_ENABLED=false면 스킵 로그). 프론트 흐름은 이메일+코드 입력.
// 링크는 안내용(프론트 reset 화면은 코드 6자리 입력을 사용).
String link = mailProperties.getWebBaseUrl() + "/login?reset=1";
String html = com.zioinfo.kintex.mail.MailTemplates.passwordReset(code, link, RESET_TTL_MINUTES);
mailService.send("PASSWORD_RESET", null, email, "[KINTEX] 비밀번호 재설정 인증코드", html);
} }
// 존재 여부와 무관하게 동일 응답(사용자 열거 방지). // 존재 여부와 무관하게 동일 응답(사용자 열거 방지).
return new MessageResponse(GENERIC_FORGOT_MESSAGE); return new MessageResponse(GENERIC_FORGOT_MESSAGE);

View File

@ -2,6 +2,8 @@ package com.zioinfo.kintex.common.error;
import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.ApiResponse.ApiError; import com.zioinfo.kintex.common.ApiResponse.ApiError;
import com.zioinfo.kintex.system.errorlog.ErrorLogService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -12,12 +14,19 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
/** /**
* 전역 예외 처리 응답에는 코드+요약 메시지만 노출한다. * 전역 예외 처리 응답에는 코드+요약 메시지만 노출한다.
* 스택트레이스·내부 세부는 서버 로그에만 남기고 클라이언트에는 절대 반환하지 않는다(보안 불변). * 스택트레이스·내부 세부는 서버 로그에만 남기고 클라이언트에는 절대 반환하지 않는다(보안 불변).
* 예상치 못한 500 관리 조회용으로 요약 1줄만 error_log best-effort 적재한다(스택 미저장).
*/ */
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final ErrorLogService errorLog;
public GlobalExceptionHandler(ErrorLogService errorLog) {
this.errorLog = errorLog;
}
@ExceptionHandler(ApiException.class) @ExceptionHandler(ApiException.class)
public ResponseEntity<ApiResponse<Void>> handleApi(ApiException ex) { public ResponseEntity<ApiResponse<Void>> handleApi(ApiException ex) {
ErrorCode code = ex.getCode(); ErrorCode code = ex.getCode();
@ -39,9 +48,15 @@ public class GlobalExceptionHandler {
/** 최종 방어선 — 예상치 못한 예외는 요약 메시지만, 상세는 로그로. */ /** 최종 방어선 — 예상치 못한 예외는 요약 메시지만, 상세는 로그로. */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception ex) { public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception ex, HttpServletRequest req) {
log.error("Unhandled exception", ex); log.error("Unhandled exception", ex);
ErrorCode code = ErrorCode.INTERNAL; ErrorCode code = ErrorCode.INTERNAL;
// 요약 1줄만(예외 클래스 + 메시지 절단). 스택트레이스·민감정보는 저장하지 않는다.
String summary = ex.getClass().getSimpleName()
+ (ex.getMessage() == null ? "" : ": " + ex.getMessage());
errorLog.record(code.name(), summary,
req == null ? null : req.getRequestURI(),
req == null ? null : req.getMethod(), null);
return ResponseEntity.status(code.status()) return ResponseEntity.status(code.status())
.body(ApiResponse.fail(new ApiError(code.name(), code.defaultMessage()))); .body(ApiResponse.fail(new ApiError(code.name(), code.defaultMessage())));
} }

View File

@ -0,0 +1,56 @@
package com.zioinfo.kintex.common.text;
/**
* PII 마스킹 유틸(§0-3) 전화/이메일/IP 원문을 응답·로그·저장 전에 마스킹한다.
* 시스템관리 조회(회사 연락처·로그인 이력)에서 공용으로 사용한다.
*/
public final class Masking {
private Masking() {
}
/** 010-1234-5678 → 010-****-5678. 형식이 다르면 뒤 4자리만 노출. null 안전. */
public static String maskPhone(String phone) {
if (phone == null || phone.isBlank()) {
return phone;
}
String digits = phone.replaceAll("[^0-9]", "");
if (digits.length() < 7) {
return "***";
}
String last4 = digits.substring(digits.length() - 4);
String head = digits.substring(0, Math.min(3, digits.length() - 4));
return head + "-****-" + last4;
}
/** kim.hong@do.com → ki***@do.com. null 안전. */
public static String maskEmail(String email) {
if (email == null || email.isBlank()) {
return email;
}
int at = email.indexOf('@');
if (at <= 0) {
return "***";
}
String local = email.substring(0, at);
String domain = email.substring(at);
String head = local.length() <= 2 ? local.substring(0, 1) : local.substring(0, 2);
return head + "***" + domain;
}
/** 203.0.113.24 → 203.0.113.* (IPv6 등은 앞 절반만). null 안전. */
public static String maskIp(String ip) {
if (ip == null || ip.isBlank()) {
return ip;
}
int dot = ip.lastIndexOf('.');
if (dot > 0) {
return ip.substring(0, dot) + ".*";
}
int colon = ip.indexOf(':');
if (colon > 0) {
return ip.substring(0, colon) + ":***";
}
return "***";
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.kintex.mail;
import java.util.ArrayList;
import java.util.List;
/**
* 발송 배치 유틸 대량 수신자를 고정 크기(: 50) 청크로 분할한다. 순수 함수(단위 테스트 고정).
*/
public final class MailBatch {
private MailBatch() {
}
/** list 를 size 단위 청크로 분할. size <= 0 이면 전체를 단일 청크로 반환. */
public static <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> out = new ArrayList<>();
if (list == null || list.isEmpty()) {
return out;
}
if (size <= 0) {
out.add(new ArrayList<>(list));
return out;
}
for (int i = 0; i < list.size(); i += size) {
out.add(new ArrayList<>(list.subList(i, Math.min(i + size, list.size()))));
}
return out;
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.kintex.mail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 메일 발송 비동기 실행기. 대량 EDM 발송이 요청 스레드/공유 풀을 점유하지 않도록 소형 전용 풀을 둔다.
*/
@Configuration
@EnableAsync
public class MailConfig {
@Bean(name = "mailExecutor")
public Executor mailExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(2);
ex.setMaxPoolSize(4);
ex.setQueueCapacity(1000); // EDM 회당 상한 500 + 여유
ex.setThreadNamePrefix("mail-");
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(20);
ex.initialize();
return ex;
}
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.kintex.mail;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 메일 발송 이력 매퍼(mail_log). 본문·자격증명은 저장하지 않으며 수신자는 마스킹만 기록한다(§0-3).
* tenant_id 스키마 DEFAULT 'KINTEX'(V31 표준) 위임 삽입 생략한다.
*/
@Mapper
public interface MailLogMapper {
@Insert("""
INSERT INTO mail_log (id, category, ref_id, subject, recipient_masked, status, detail)
VALUES (#{id}, #{category}, #{refId}, #{subject}, #{recipientMasked}, #{status}, #{detail})
""")
int insert(@Param("id") String id,
@Param("category") String category,
@Param("refId") String refId,
@Param("subject") String subject,
@Param("recipientMasked") String recipientMasked,
@Param("status") String status,
@Param("detail") String detail);
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.kintex.mail;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 메일 발송 채널 설정 자체 Postfix SMTP(mail.zioinfo.co.kr, GUARDiA 운영).
*
* <p>SMTP 자격증명(host/user/password) {@code spring.mail.*}(env: SMTP_HOST·SMTP_USER·SMTP_PASSWORD)로만 주입한다.
* 여기서는 발송 on/off({@code enabled}) 발신자 표기({@code from}) 다룬다. 자격증명은 코드·응답·로그에 기재 금지(§0-3).
*
* <ul>
* <li>{@code MAIL_ENABLED}(기본 false) 발송 스위치. false면 인앱/로깅만 하고 실제 전송은 건너뛴다(정직 로그).</li>
* <li>{@code MAIL_FROM} From 헤더 표기 주소.</li>
* </ul>
*/
@ConfigurationProperties(prefix = "kintex.mail")
public class MailProperties {
/** 실 발송 여부(기본 false — env로 켠다). false면 실제 SMTP 전송 없이 스킵 로그만 남긴다. */
private boolean enabled = false;
/** 발신자 주소(From). */
private String from = "no-reply@kintex.zioinfo.co.kr";
/** 프론트 베이스 URL — 메일 내 링크(옥션 상세·재설정 화면) 생성용. */
private String webBaseUrl = "https://kintex.zioinfo.co.kr";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getFrom() {
return from;
}
public void setFrom(String from) {
this.from = from;
}
public String getWebBaseUrl() {
return webBaseUrl;
}
public void setWebBaseUrl(String webBaseUrl) {
this.webBaseUrl = webBaseUrl;
}
}

View File

@ -0,0 +1,120 @@
package com.zioinfo.kintex.mail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* 메일 발송 서비스 자체 Postfix SMTP(mail.zioinfo.co.kr) 채널.
*
* <p><b>불변 규칙</b>:
* <ul>
* <li>SMTP 자격증명은 {@code spring.mail.*}(env)로만 로드 코드·응답·로그 미기재(§0-3).</li>
* <li>실패 격리: 발송 실패는 호출 기능(옥션 개설·비번 재설정 )으로 전파하지 않는다. 1회 재시도 이력만 남긴다.</li>
* <li>수신자는 마스킹(kim***@do.com) 로그·이력에 기록. 본문은 저장하지 않는다.</li>
* <li>{@code MAIL_ENABLED=false} 실제 전송 없이 skipped 이력만 남긴다(정직 로그).</li>
* </ul>
*
* <p>@Async 호출 스레드를 차단하지 않는다(대량 EDM 발송 요청 응답 지연 방지).
*/
@Service
public class MailService {
private static final Logger log = LoggerFactory.getLogger(MailService.class);
private final MailProperties props;
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final MailLogMapper mailLogMapper;
public MailService(MailProperties props,
ObjectProvider<JavaMailSender> mailSenderProvider,
MailLogMapper mailLogMapper) {
this.props = props;
this.mailSenderProvider = mailSenderProvider;
this.mailLogMapper = mailLogMapper;
}
/**
* 단건 HTML 메일 발송(비동기·실패 격리). category/refId 이력 분류용, to 발송 경로 한정 원문 수신자.
* MAIL_ENABLED=false 또는 JavaMailSender 미구성 실제 전송 없이 skipped 이력만 남긴다.
*/
@Async("mailExecutor")
public void send(String category, String refId, String to, String subject, String htmlBody) {
String masked = maskEmail(to);
if (to == null || to.isBlank()) {
record(category, refId, subject, masked, "skipped", "빈 수신자");
return;
}
JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (!props.isEnabled() || sender == null) {
// 발송 비활성 스킵 이력만(원문·자격증명 미기록).
log.info("메일 발송 스킵(MAIL_ENABLED=false){}: cat={} to={} subj={}",
sender == null ? "/sender-absent" : "", category, masked, subject);
record(category, refId, subject, masked, "skipped", "MAIL_ENABLED=false");
return;
}
// 발송 1회 재시도. 최종 실패도 예외 전파 없이 이력만.
RuntimeException last = null;
for (int attempt = 1; attempt <= 2; attempt++) {
try {
var message = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
helper.setFrom(props.getFrom());
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlBody, true);
sender.send(message);
record(category, refId, subject, masked, "sent", null);
return;
} catch (Exception e) {
last = e instanceof RuntimeException re ? re : new RuntimeException(e.getClass().getSimpleName());
log.warn("메일 발송 실패(시도 {}/2): cat={} to={} reason={}",
attempt, category, masked, e.getClass().getSimpleName());
}
}
record(category, refId, subject, masked, "failed",
last == null ? "unknown" : last.getClass().getSimpleName());
}
/** 이력 기록(실패해도 호출 기능에 전파하지 않는다). */
private void record(String category, String refId, String subject, String masked,
String status, String detail) {
try {
mailLogMapper.insert("ml-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20),
category, refId, truncate(subject, 300), masked, status, truncate(detail, 300));
} catch (RuntimeException e) {
log.warn("메일 이력 기록 실패(무시): cat={} status={} reason={}", category, status, e.getClass().getSimpleName());
}
}
/** 이메일 마스킹 — kim***@domain.com. 로그·이력 노출 안전 형식. */
static String maskEmail(String email) {
if (email == null || email.isBlank()) {
return "***";
}
int at = email.indexOf('@');
if (at <= 0) {
return "***";
}
String local = email.substring(0, at);
String domain = email.substring(at);
if (local.length() <= 3) {
return local.charAt(0) + "***" + domain;
}
return local.substring(0, 3) + "***" + domain;
}
private static String truncate(String s, int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}

View File

@ -0,0 +1,67 @@
package com.zioinfo.kintex.mail;
/**
* 짧은 HTML 메일 본문 템플릿. 인라인 스타일 최소·외부 리소스 없음. 본문은 저장하지 않는다(mail_log 제목만).
* 정보통신망법: 광고성(EDM)에는 수신거부 안내 문구를 하단에 포함한다.
*/
public final class MailTemplates {
private MailTemplates() {
}
private static final String WRAP_HEAD =
"<div style=\"font-family:'Pretendard',Arial,sans-serif;max-width:560px;margin:0 auto;"
+ "color:#1f2937;line-height:1.6\">";
private static final String WRAP_FOOT =
"<hr style=\"border:none;border-top:1px solid #e5e7eb;margin:24px 0 12px\">"
+ "<p style=\"font-size:12px;color:#9ca3af\">KINTEX 전시운영 시스템 · 본 메일은 발신 전용입니다.</p></div>";
/** 옥션 개설 통지(공사업체 대상) — 행사명·옥션 제목·마감·자료 열람 링크. */
public static String auctionOpened(String eventName, String auctionTitle, String deadline, String link) {
return WRAP_HEAD
+ "<h2 style=\"font-size:18px\">새 공사 옥션이 개설되었습니다</h2>"
+ "<p><strong>행사</strong> " + esc(eventName) + "</p>"
+ "<p><strong>옥션</strong> " + esc(auctionTitle) + "</p>"
+ "<p><strong>응찰 마감</strong> " + esc(deadline) + "</p>"
+ "<p style=\"margin-top:16px\"><a href=\"" + esc(link) + "\" "
+ "style=\"display:inline-block;background:#1f29fc;color:#fff;padding:10px 18px;"
+ "border-radius:8px;text-decoration:none\">자료 열람 및 응찰</a></p>"
+ "<p style=\"font-size:13px;color:#6b7280\">봉인 입찰 — 마감 전 타사 응찰가는 공개되지 않습니다.</p>"
+ WRAP_FOOT;
}
/** 비밀번호 재설정 — 6자리 인증코드 + 로그인 페이지 링크. */
public static String passwordReset(String code, String link, int ttlMinutes) {
return WRAP_HEAD
+ "<h2 style=\"font-size:18px\">비밀번호 재설정 안내</h2>"
+ "<p>아래 인증코드를 재설정 화면에 입력해 주세요. 유효시간은 " + ttlMinutes + "분입니다.</p>"
+ "<p style=\"font-size:28px;font-weight:700;letter-spacing:6px;color:#1f29fc;margin:16px 0\">"
+ esc(code) + "</p>"
+ "<p style=\"margin-top:8px\"><a href=\"" + esc(link) + "\" "
+ "style=\"color:#1f29fc\">재설정 화면 열기</a></p>"
+ "<p style=\"font-size:13px;color:#6b7280\">본인이 요청하지 않았다면 이 메일을 무시하세요.</p>"
+ WRAP_FOOT;
}
/** EDM 광고 본문 — 캠페인 명 + 안내 + 정보통신망법 수신거부 문구. */
public static String edm(String campaignName, String eventName) {
return WRAP_HEAD
+ "<p style=\"font-size:12px;color:#9ca3af\">(광고) " + esc(eventName) + "</p>"
+ "<h2 style=\"font-size:18px\">" + esc(campaignName) + "</h2>"
+ "<p>안녕하세요. KINTEX가 준비한 전시·행사 소식을 전해드립니다. "
+ "자세한 내용과 사전등록은 아래에서 확인하실 수 있습니다.</p>"
+ "<hr style=\"border:none;border-top:1px solid #e5e7eb;margin:24px 0 12px\">"
+ "<p style=\"font-size:12px;color:#9ca3af\">본 메일은 수신동의(마케팅 정보 수신)를 하신 분께 발송되었습니다. "
+ "수신을 원치 않으시면 마이페이지 &gt; 수신설정에서 수신거부하실 수 있습니다. "
+ "(정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)</p>"
+ "<p style=\"font-size:12px;color:#9ca3af\">KINTEX 전시운영 시스템 · 발신 전용</p></div>";
}
/** 최소 HTML 이스케이프(제한 문자만) — 사용자 입력 값이 본문에 삽입될 때 태그 주입 방지. */
private static String esc(String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}

View File

@ -46,6 +46,16 @@ public class MarketingController {
return ApiResponse.ok(service.createCampaign(eventId, req)); return ApiResponse.ok(service.createCampaign(eventId, req));
} }
/** 캠페인 발송(수신동의자 한정) — 매니저 이상. 정보통신망법 준수·회당 상한·배치 분할은 서비스가 강제. */
@Audited(action = "CAMPAIGN_SEND", targetType = "edm_campaign")
@PostMapping("/campaigns/{id}/send")
public ApiResponse<CampaignSendResult> sendCampaign(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String id) {
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
return ApiResponse.ok(service.sendCampaign(eventId, id));
}
/** 스폰서십 패키지·판매 현황. */ /** 스폰서십 패키지·판매 현황. */
@GetMapping("/sponsorship") @GetMapping("/sponsorship")
public ApiResponse<SponsorshipView> sponsorship(@AuthenticationPrincipal KintexPrincipal principal, public ApiResponse<SponsorshipView> sponsorship(@AuthenticationPrincipal KintexPrincipal principal,

View File

@ -43,6 +43,40 @@ public interface MarketingMapper {
@Select("SELECT count(*) FROM event WHERE id = #{eventId}") @Select("SELECT count(*) FROM event WHERE id = #{eventId}")
long eventExists(@Param("eventId") String eventId); long eventExists(@Param("eventId") String eventId);
// 캠페인 발송(수신동의자 한정)
/** 캠페인 단건(발송 게이트용) — 소속 행사·현재 상태. */
@Select("""
SELECT id, event_id AS "eventId", name, status
FROM edm_campaign
WHERE id = #{id} AND event_id = #{eventId}
""")
Map<String, Object> findCampaign(@Param("eventId") String eventId, @Param("id") String id);
/**
* 발송 대상 이메일(수신동의 = agree_marketing=true, 이메일 보유). 원문 이메일은 발송 경로 한정
* 응답/로그에 노출하지 않는다. 회당 상한(limit) 가드는 서비스가 적용.
*/
@Select("""
SELECT DISTINCT lower(email) AS "email"
FROM visitor_registration
WHERE event_id = #{eventId}
AND agree_marketing = true
AND email IS NOT NULL AND email <> ''
ORDER BY lower(email)
LIMIT #{limit}
""")
List<Map<String, Object>> listMarketingRecipients(@Param("eventId") String eventId, @Param("limit") int limit);
/** 발송 결과 반영 — 상태·발송 건수(audience)·표시 메타 갱신. */
@org.apache.ibatis.annotations.Update("""
UPDATE edm_campaign
SET status = #{status}, audience = #{sentCount}, meta = #{meta}, updated_at = now()
WHERE id = #{id} AND event_id = #{eventId}
""")
int markCampaignSent(@Param("eventId") String eventId, @Param("id") String id,
@Param("status") String status, @Param("sentCount") int sentCount,
@Param("meta") String meta);
// 스폰서십 // 스폰서십
@Select(""" @Select("""
SELECT id, code, name, name_ko AS "nameKo", price, accent, SELECT id, code, name, name_ko AS "nameKo", price, accent,

View File

@ -20,10 +20,20 @@ public class MarketingService {
private static final ObjectMapper JSON = new ObjectMapper(); private static final ObjectMapper JSON = new ObjectMapper();
private final MarketingMapper mapper; /** EDM 회당 발송 상한(가드) 및 배치 크기. */
private static final int SEND_CAP = 500;
private static final int BATCH_SIZE = 50;
public MarketingService(MarketingMapper mapper) { private final MarketingMapper mapper;
private final com.zioinfo.kintex.mail.MailService mailService;
private final com.zioinfo.kintex.mail.MailProperties mailProperties;
public MarketingService(MarketingMapper mapper,
com.zioinfo.kintex.mail.MailService mailService,
com.zioinfo.kintex.mail.MailProperties mailProperties) {
this.mapper = mapper; this.mapper = mapper;
this.mailService = mailService;
this.mailProperties = mailProperties;
} }
// 캠페인 // 캠페인
@ -60,6 +70,54 @@ public class MarketingService {
return new CampaignDto(id, name, status, audience, meta, "-", "-"); return new CampaignDto(id, name, status, audience, meta, "-", "-");
} }
/**
* 캠페인 발송(수신동의자 한정). 정보통신망법 준수: agree_marketing=true + 이메일 보유자만,
* 회당 상한 {@value #SEND_CAP} , {@value #BATCH_SIZE} 배치. 상태를 done 으로 전이하고 발송 건수를 기록한다.
* 수신자 원문 이메일은 발송 경로 한정 응답/로그에는 건수만 노출한다(§0-3).
*/
public CampaignSendResult sendCampaign(String eventId, String campaignId) {
Map<String, Object> c = mapper.findCampaign(eventId, campaignId);
if (c == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 캠페인입니다.");
}
String status = str(c.get("status"));
if ("sending".equalsIgnoreCase(status) || "done".equalsIgnoreCase(status)) {
throw new ApiException(ErrorCode.CONFLICT, "이미 발송되었거나 발송 중인 캠페인입니다.");
}
String name = str(c.get("name"));
String eventName = null;
List<Map<String, Object>> recipients = mapper.listMarketingRecipients(eventId, SEND_CAP);
List<String> emails = new ArrayList<>();
if (recipients != null) {
for (Map<String, Object> r : recipients) {
String e = str(r.get("email"));
if (e != null && !e.isBlank()) {
emails.add(e);
}
}
}
String subject = "[KINTEX] " + (name == null ? "행사 소식" : name);
String html = com.zioinfo.kintex.mail.MailTemplates.edm(name, eventName == null ? "KINTEX" : eventName);
int sent = 0;
// 50건 배치로 분할 발송( 건은 MailService @Async·실패 격리). MAIL_ENABLED=false면 내부에서 스킵.
for (List<String> batch : com.zioinfo.kintex.mail.MailBatch.partition(emails, BATCH_SIZE)) {
for (String email : batch) {
mailService.send("EDM", campaignId, email, subject, html);
sent++;
}
}
boolean mailEnabled = mailProperties.isEnabled();
String meta = mailEnabled
? "발송 완료: " + sent + "건 (수신동의자)"
: "발송 대상 " + sent + "건 (MAIL 비활성 — 실 전송 없음)";
mapper.markCampaignSent(eventId, campaignId, "done", sent, meta);
return new CampaignSendResult(campaignId, "done", sent, mailEnabled, meta);
}
// 스폰서십 // 스폰서십
public SponsorshipView sponsorship(String eventId) { public SponsorshipView sponsorship(String eventId) {
List<Map<String, Object>> pkgRows = mapper.findPackages(eventId); List<Map<String, Object>> pkgRows = mapper.findPackages(eventId);

View File

@ -21,6 +21,11 @@ public final class MarketingDtos {
String scheduleType, String scheduledAt) { String scheduleType, String scheduledAt) {
} }
/** 캠페인 발송 결과 — 발송 건수·상태. 수신자 원문/PII 는 포함하지 않는다. */
public record CampaignSendResult(String id, String status, int sentCount, boolean mailEnabled,
String meta) {
}
// SCR-34 스폰서십 // SCR-34 스폰서십
public record SponsorshipView(List<MktKpi> kpis, List<TierDto> tiers, List<SponsorDto> sponsors) { public record SponsorshipView(List<MktKpi> kpis, List<TierDto> tiers, List<SponsorDto> sponsors) {
} }

View File

@ -2,7 +2,11 @@ package com.zioinfo.kintex.security;
import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.system.loginhistory.LoginHistoryService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Map; import java.util.Map;
@ -10,16 +14,20 @@ import java.util.Map;
/** /**
* 로그인 실패 잠금 정책 연속 실패 임계 초과 {@code locked_until} 설정(계약 §5B-3). * 로그인 실패 잠금 정책 연속 실패 임계 초과 {@code locked_until} 설정(계약 §5B-3).
* 실패 메시지는 계정 열거를 막기 위해 일반화한다. * 실패 메시지는 계정 열거를 막기 위해 일반화한다.
* 부가로 로그인 이력(성공/실패) best-effort 기록한다(이력 실패가 인증을 막지 않음).
*/ */
@Service @Service
public class LoginAttemptService { public class LoginAttemptService {
private final AccountSecurityMapper mapper; private final AccountSecurityMapper mapper;
private final SecurityPolicyProperties props; private final SecurityPolicyProperties props;
private final LoginHistoryService loginHistory;
public LoginAttemptService(AccountSecurityMapper mapper, SecurityPolicyProperties props) { public LoginAttemptService(AccountSecurityMapper mapper, SecurityPolicyProperties props,
LoginHistoryService loginHistory) {
this.mapper = mapper; this.mapper = mapper;
this.props = props; this.props = props;
this.loginHistory = loginHistory;
} }
/** 현재 잠금 상태면 423(LOCKED). authRow 는 findAuthSecurityByEmail 결과. */ /** 현재 잠금 상태면 423(LOCKED). authRow 는 findAuthSecurityByEmail 결과. */
@ -36,6 +44,8 @@ public class LoginAttemptService {
/** 자격 실패 기록 — 임계 초과 시 잠금. */ /** 자격 실패 기록 — 임계 초과 시 잠금. */
public void recordFailure(String email, Map<String, Object> authRow) { public void recordFailure(String email, Map<String, Object> authRow) {
// 이력은 계정 존재 여부와 무관하게 기록(마스킹 이메일만) 침해 탐지 가치.
loginHistory.recordFailure(email, "BAD_CREDENTIAL", clientIp(), userAgent());
if (authRow == null) { if (authRow == null) {
return; // 미존재 계정은 상태 변경 없음. return; // 미존재 계정은 상태 변경 없음.
} }
@ -49,6 +59,32 @@ public class LoginAttemptService {
/** 로그인 성공 — 실패 카운트/잠금 해제. */ /** 로그인 성공 — 실패 카운트/잠금 해제. */
public void recordSuccess(String userId) { public void recordSuccess(String userId) {
mapper.clearLoginState(userId); mapper.clearLoginState(userId);
loginHistory.recordSuccess(userId, null, clientIp(), userAgent());
}
/** 현재 요청의 클라이언트 IP(X-Forwarded-For 우선). 요청 컨텍스트 없으면 null. */
private static String clientIp() {
HttpServletRequest req = currentRequest();
if (req == null) {
return null;
}
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
return req.getRemoteAddr();
}
private static String userAgent() {
HttpServletRequest req = currentRequest();
return req == null ? null : req.getHeader("User-Agent");
}
private static HttpServletRequest currentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
return attrs.getRequest();
}
return null;
} }
private static int toInt(Object o) { private static int toInt(Object o) {

View File

@ -0,0 +1,37 @@
package com.zioinfo.kintex.system.authpolicy;
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.*;
/** 시스템관리 — 인증 정책 API. ADMIN 게이트. */
@RestController
@RequestMapping("/api/admin/auth-policy")
public class AuthPolicyController {
private final AuthPolicyService service;
private final SystemAccessGuard guard;
public AuthPolicyController(AuthPolicyService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<AuthPolicyDto> get(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.get());
}
@Audited(action = "AUTH_POLICY_SAVE", targetType = "sys_auth_policy")
@PutMapping
public ApiResponse<Void> save(@AuthenticationPrincipal KintexPrincipal principal,
@RequestBody AuthPolicySaveRequest req) {
guard.requireAdmin(principal);
service.save(req, principal.userId());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.kintex.system.authpolicy;
/** 인증 정책(비밀번호·로그인 잠금·세션). 시크릿 없음. */
public record AuthPolicyDto(
Integer pwMinLength,
String pwRequireUpper,
String pwRequireNumber,
String pwRequireSpecial,
Integer pwExpireDays,
Integer loginMaxFail,
Integer lockMinutes,
Integer sessionTimeoutMin,
String updatedAt,
String updatedBy
) {
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.kintex.system.authpolicy;
import org.apache.ibatis.annotations.*;
import java.util.Map;
/** 인증 정책 매퍼(sys_auth_policy). 테넌트 싱글턴 1행. */
@Mapper
public interface AuthPolicyMapper {
@Select("SELECT pw_min_length AS \"pwMinLength\", pw_require_upper AS \"pwRequireUpper\", "
+ "pw_require_number AS \"pwRequireNumber\", pw_require_special AS \"pwRequireSpecial\", "
+ "pw_expire_days AS \"pwExpireDays\", login_max_fail AS \"loginMaxFail\", "
+ "lock_minutes AS \"lockMinutes\", session_timeout_min AS \"sessionTimeoutMin\", "
+ "to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS \"updatedAt\", "
+ "updated_by AS \"updatedBy\" "
+ "FROM sys_auth_policy WHERE tenant_id = #{tenantId}")
Map<String, Object> find(@Param("tenantId") String tenantId);
@Insert("INSERT INTO sys_auth_policy (tenant_id, pw_min_length, pw_require_upper, pw_require_number, "
+ "pw_require_special, pw_expire_days, login_max_fail, lock_minutes, session_timeout_min, updated_by) "
+ "VALUES (#{tenantId}, #{pwMinLength}, #{pwRequireUpper}, #{pwRequireNumber}, #{pwRequireSpecial}, "
+ "#{pwExpireDays}, #{loginMaxFail}, #{lockMinutes}, #{sessionTimeoutMin}, #{updatedBy}) "
+ "ON CONFLICT (tenant_id) DO UPDATE SET pw_min_length=EXCLUDED.pw_min_length, "
+ "pw_require_upper=EXCLUDED.pw_require_upper, pw_require_number=EXCLUDED.pw_require_number, "
+ "pw_require_special=EXCLUDED.pw_require_special, pw_expire_days=EXCLUDED.pw_expire_days, "
+ "login_max_fail=EXCLUDED.login_max_fail, lock_minutes=EXCLUDED.lock_minutes, "
+ "session_timeout_min=EXCLUDED.session_timeout_min, updated_at=now(), updated_by=EXCLUDED.updated_by")
int upsert(Map<String, Object> p);
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.kintex.system.authpolicy;
/** 인증 정책 저장 본문(서버에서 범위 클램프). */
public record AuthPolicySaveRequest(
Integer pwMinLength,
String pwRequireUpper,
String pwRequireNumber,
String pwRequireSpecial,
Integer pwExpireDays,
Integer loginMaxFail,
Integer lockMinutes,
Integer sessionTimeoutMin
) {
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.kintex.system.authpolicy;
import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
/**
* 인증 정책 서비스 비밀번호 정책·로그인 실패 잠금 임계·세션 타임아웃(관리 조회/변경).
* 실제 잠금 강제는 LoginAttemptService/sys_setting(login.*) 담당하며, 정책은 관리 화면 계약이다
* (수치 정합: login_max_faillogin.maxFailedAttempts, lock_minuteslogin.lockMinutes).
*/
@Service
public class AuthPolicyService {
private final AuthPolicyMapper mapper;
public AuthPolicyService(AuthPolicyMapper mapper) {
this.mapper = mapper;
}
public AuthPolicyDto get() {
Map<String, Object> r = mapper.find(TenantContext.currentTenantId());
if (r == null) {
// 표준 기본값(마이그레이션 시드가 없을 경우의 방어).
return new AuthPolicyDto(8, "N", "Y", "N", 0, 5, 15, 120, null, null);
}
return new AuthPolicyDto(
intv(r.get("pwMinLength")), str(r.get("pwRequireUpper")), str(r.get("pwRequireNumber")),
str(r.get("pwRequireSpecial")), intv(r.get("pwExpireDays")), intv(r.get("loginMaxFail")),
intv(r.get("lockMinutes")), intv(r.get("sessionTimeoutMin")),
str(r.get("updatedAt")), str(r.get("updatedBy")));
}
@Transactional
public void save(AuthPolicySaveRequest req, String actorId) {
Map<String, Object> p = new HashMap<>();
p.put("tenantId", TenantContext.currentTenantId());
p.put("pwMinLength", clamp(req.pwMinLength(), 4, 64, 8));
p.put("pwRequireUpper", yn(req.pwRequireUpper()));
p.put("pwRequireNumber", yn(req.pwRequireNumber()));
p.put("pwRequireSpecial", yn(req.pwRequireSpecial()));
p.put("pwExpireDays", clamp(req.pwExpireDays(), 0, 3650, 0));
p.put("loginMaxFail", clamp(req.loginMaxFail(), 1, 20, 5));
p.put("lockMinutes", clamp(req.lockMinutes(), 1, 1440, 15));
p.put("sessionTimeoutMin", clamp(req.sessionTimeoutMin(), 5, 1440, 120));
p.put("updatedBy", actorId);
mapper.upsert(p);
}
private static String yn(String v) { return "Y".equalsIgnoreCase(v) ? "Y" : "N"; }
private static int clamp(Integer v, int min, int max, int def) {
int x = v == null ? def : v;
return Math.max(min, Math.min(max, x));
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static Integer intv(Object o) {
if (o instanceof Number n) return n.intValue();
return o == null ? null : Integer.parseInt(String.valueOf(o));
}
}

View File

@ -0,0 +1,53 @@
package com.zioinfo.kintex.system.company;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.audit.Audited;
import com.zioinfo.kintex.system.SystemAccessGuard;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 시스템관리 — 회사(거래처) 관리 API. ADMIN 게이트. 연락처 마스킹. */
@RestController
@RequestMapping("/api/admin/companies")
public class CompanyController {
private final CompanyService service;
private final SystemAccessGuard guard;
public CompanyController(CompanyService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<PageResponse<CompanyDto>> list(
@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String partnerType,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.list(keyword, partnerType, page, size));
}
@Audited(action = "COMPANY_SAVE", targetType = "company")
@PostMapping
public ApiResponse<Void> save(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody CompanySaveRequest req) {
guard.requireAdmin(principal);
service.save(req);
return ApiResponse.ok(null);
}
@Audited(action = "COMPANY_DELETE", targetType = "company")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.requireAdmin(principal);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.kintex.system.company;
/** 회사(거래처) 카드. contactTel 은 마스킹되어 전달된다(PII). */
public record CompanyDto(
String id,
String companyName,
String bizNo,
String ceoName,
String bizType,
String contactName,
String contactTel,
String address,
String partnerType,
String useYn
) {
}

View File

@ -0,0 +1,61 @@
package com.zioinfo.kintex.system.company;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/** 회사(거래처) 매퍼. 테넌트 스코프(V31 표준). */
@Mapper
public interface CompanyMapper {
@Select("""
<script>
SELECT id, company_name AS "companyName", biz_no AS "bizNo", ceo_name AS "ceoName",
biz_type AS "bizType", contact_name AS "contactName", contact_tel AS "contactTel",
address, partner_type AS "partnerType", use_yn AS "useYn"
FROM company
<where>
AND tenant_id = #{tenantId}
<if test="keyword != null and keyword != ''">
AND (company_name ILIKE '%'||#{keyword}||'%' OR biz_no ILIKE '%'||#{keyword}||'%'
OR contact_name ILIKE '%'||#{keyword}||'%')
</if>
<if test="partnerType != null and partnerType != ''">AND partner_type = #{partnerType}</if>
</where>
ORDER BY company_name
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(@Param("tenantId") String tenantId, @Param("keyword") String keyword,
@Param("partnerType") String partnerType,
@Param("size") int size, @Param("offset") int offset);
@Select("""
<script>
SELECT count(*) FROM company
<where>
AND tenant_id = #{tenantId}
<if test="keyword != null and keyword != ''">
AND (company_name ILIKE '%'||#{keyword}||'%' OR biz_no ILIKE '%'||#{keyword}||'%'
OR contact_name ILIKE '%'||#{keyword}||'%')
</if>
<if test="partnerType != null and partnerType != ''">AND partner_type = #{partnerType}</if>
</where>
</script>
""")
long count(@Param("tenantId") String tenantId, @Param("keyword") String keyword,
@Param("partnerType") String partnerType);
@Insert("INSERT INTO company (tenant_id, id, company_name, biz_no, ceo_name, biz_type, contact_name, "
+ "contact_tel, address, partner_type, use_yn) VALUES (#{tenantId}, #{id}, #{companyName}, #{bizNo}, "
+ "#{ceoName}, #{bizType}, #{contactName}, #{contactTel}, #{address}, #{partnerType}, COALESCE(#{useYn},'Y')) "
+ "ON CONFLICT (tenant_id, id) DO UPDATE SET company_name=EXCLUDED.company_name, biz_no=EXCLUDED.biz_no, "
+ "ceo_name=EXCLUDED.ceo_name, biz_type=EXCLUDED.biz_type, contact_name=EXCLUDED.contact_name, "
+ "contact_tel=EXCLUDED.contact_tel, address=EXCLUDED.address, partner_type=EXCLUDED.partner_type, "
+ "use_yn=EXCLUDED.use_yn, updated_at=now()")
int upsert(Map<String, Object> p);
@Delete("DELETE FROM company WHERE tenant_id = #{tenantId} AND id = #{id}")
int delete(@Param("tenantId") String tenantId, @Param("id") String id);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.kintex.system.company;
import jakarta.validation.constraints.NotBlank;
/** 회사 저장 본문. id 가 있으면 수정, 없으면 신규. */
public record CompanySaveRequest(
String id,
@NotBlank String companyName,
String bizNo,
String ceoName,
String bizType,
String contactName,
String contactTel,
String address,
String partnerType,
String useYn
) {
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.kintex.system.company;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.common.text.Masking;
import com.zioinfo.kintex.tenant.TenantContext;
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;
/** 회사(거래처) 관리 서비스. 연락처는 마스킹 후 응답(§0-3). 테넌트 스코프. */
@Service
public class CompanyService {
private final CompanyMapper mapper;
public CompanyService(CompanyMapper mapper) {
this.mapper = mapper;
}
public PageResponse<CompanyDto> list(String keyword, String partnerType, int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 20 : Math.min(size, 200);
String tenantId = TenantContext.currentTenantId();
List<Map<String, Object>> rows = mapper.search(tenantId, keyword, partnerType, s, p * s);
long total = mapper.count(tenantId, keyword, partnerType);
return PageResponse.of(rows.stream().map(CompanyService::toDto).toList(), p, s, total);
}
@Transactional
public void save(CompanySaveRequest req) {
String id = (req.id() == null || req.id().isBlank())
? "co-" + UUID.randomUUID().toString().substring(0, 12) : req.id();
Map<String, Object> pm = new HashMap<>();
pm.put("tenantId", TenantContext.currentTenantId());
pm.put("id", id);
pm.put("companyName", req.companyName());
pm.put("bizNo", req.bizNo());
pm.put("ceoName", req.ceoName());
pm.put("bizType", req.bizType());
pm.put("contactName", req.contactName());
pm.put("contactTel", req.contactTel());
pm.put("address", req.address());
pm.put("partnerType", req.partnerType());
pm.put("useYn", req.useYn());
mapper.upsert(pm);
}
@Transactional
public void delete(String id) {
if (mapper.delete(TenantContext.currentTenantId(), id) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
}
private static CompanyDto toDto(Map<String, Object> r) {
return new CompanyDto(
str(r.get("id")), str(r.get("companyName")), str(r.get("bizNo")), str(r.get("ceoName")),
str(r.get("bizType")), str(r.get("contactName")), Masking.maskPhone(str(r.get("contactTel"))),
str(r.get("address")), str(r.get("partnerType")), str(r.get("useYn")));
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
}

View File

@ -0,0 +1,49 @@
package com.zioinfo.kintex.system.dept;
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 jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 시스템관리 — 조직(부서 트리) 관리 API. ADMIN 게이트. */
@RestController
@RequestMapping("/api/admin/depts")
public class DeptController {
private final DeptService service;
private final SystemAccessGuard guard;
public DeptController(DeptService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<List<DeptDto>> tree(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.tree());
}
@Audited(action = "DEPT_SAVE", targetType = "dept")
@PostMapping
public ApiResponse<Void> save(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody DeptSaveRequest req) {
guard.requireAdmin(principal);
service.save(req);
return ApiResponse.ok(null);
}
@Audited(action = "DEPT_DELETE", targetType = "dept")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.requireAdmin(principal);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.kintex.system.dept;
import java.util.List;
/** 부서 노드(트리). children 은 sort_order→id 순 중첩. */
public record DeptDto(
String id,
String deptName,
String parentId,
Integer sortOrder,
String useYn,
List<DeptDto> children
) {
}

View File

@ -0,0 +1,28 @@
package com.zioinfo.kintex.system.dept;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/** 부서 매퍼(dept). 테넌트 스코프(V31 표준). */
@Mapper
public interface DeptMapper {
@Select("SELECT id, dept_name AS \"deptName\", parent_id AS \"parentId\", "
+ "sort_order AS \"sortOrder\", use_yn AS \"useYn\" "
+ "FROM dept WHERE tenant_id = #{tenantId} ORDER BY sort_order, id")
List<Map<String, Object>> findAll(@Param("tenantId") String tenantId);
@Insert("INSERT INTO dept (tenant_id, id, dept_name, parent_id, sort_order, use_yn) "
+ "VALUES (#{tenantId}, #{id}, #{deptName}, #{parentId}, COALESCE(#{sortOrder},0), COALESCE(#{useYn},'Y')) "
+ "ON CONFLICT (tenant_id, id) DO UPDATE SET dept_name=EXCLUDED.dept_name, parent_id=EXCLUDED.parent_id, "
+ "sort_order=EXCLUDED.sort_order, use_yn=EXCLUDED.use_yn, updated_at=now()")
int upsert(Map<String, Object> p);
@Delete("DELETE FROM dept WHERE tenant_id = #{tenantId} AND id = #{id}")
int delete(@Param("tenantId") String tenantId, @Param("id") String id);
@Select("SELECT count(*) FROM dept WHERE tenant_id = #{tenantId} AND parent_id = #{id}")
int countChildren(@Param("tenantId") String tenantId, @Param("id") String id);
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.kintex.system.dept;
import jakarta.validation.constraints.NotBlank;
/** 부서 저장 본문. id 가 있으면 수정, 없으면 신규(백엔드 upsert). */
public record DeptSaveRequest(
String id,
@NotBlank String deptName,
String parentId,
Integer sortOrder,
String useYn
) {
}

View File

@ -0,0 +1,84 @@
package com.zioinfo.kintex.system.dept;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** 조직 — 부서 트리 서비스. 테넌트 컨텍스트로 스코프(§8-2). */
@Service
public class DeptService {
private final DeptMapper mapper;
public DeptService(DeptMapper mapper) {
this.mapper = mapper;
}
/** 전체 부서를 트리로 조립(루트=parentId null 또는 미존재 부모). */
public List<DeptDto> tree() {
String tenantId = TenantContext.currentTenantId();
List<Map<String, Object>> rows = mapper.findAll(tenantId);
Map<String, List<DeptDto>> childrenOf = new LinkedHashMap<>();
Map<String, Map<String, Object>> byId = new LinkedHashMap<>();
for (Map<String, Object> r : rows) {
byId.put(str(r.get("id")), r);
}
List<DeptDto> roots = new ArrayList<>();
// 재귀 없이 childrenOf 맵으로 조립(rows 순서=sort_order,id 안정 정렬).
for (Map<String, Object> r : rows) {
String id = str(r.get("id"));
String parentId = str(r.get("parentId"));
List<DeptDto> kids = childrenOf.computeIfAbsent(id, k -> new ArrayList<>());
DeptDto node = new DeptDto(id, str(r.get("deptName")), parentId,
intv(r.get("sortOrder")), str(r.get("useYn")), kids);
if (parentId == null || !byId.containsKey(parentId)) {
roots.add(node);
} else {
childrenOf.computeIfAbsent(parentId, k -> new ArrayList<>()).add(node);
}
}
return roots;
}
@Transactional
public void save(DeptSaveRequest req) {
Map<String, Object> p = new java.util.HashMap<>();
String id = (req.id() == null || req.id().isBlank())
? "dept-" + UUID.randomUUID().toString().substring(0, 12) : req.id();
if (id.equals(req.parentId())) {
throw new ApiException(ErrorCode.VALIDATION, "부모 부서를 자기 자신으로 지정할 수 없습니다.");
}
p.put("tenantId", TenantContext.currentTenantId());
p.put("id", id);
p.put("deptName", req.deptName());
p.put("parentId", (req.parentId() == null || req.parentId().isBlank()) ? null : req.parentId());
p.put("sortOrder", req.sortOrder());
p.put("useYn", req.useYn());
mapper.upsert(p);
}
@Transactional
public void delete(String id) {
String tenantId = TenantContext.currentTenantId();
if (mapper.countChildren(tenantId, id) > 0) {
throw new ApiException(ErrorCode.CONFLICT, "하위 부서가 있어 삭제할 수 없습니다.");
}
if (mapper.delete(tenantId, id) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static Integer intv(Object o) {
if (o instanceof Number n) return n.intValue();
return o == null ? 0 : Integer.parseInt(String.valueOf(o));
}
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.kintex.system.errorlog;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.system.SystemAccessGuard;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 시스템관리 — 오류 로그 조회 API. ADMIN 게이트. 읽기 전용(요약 1줄). */
@RestController
@RequestMapping("/api/admin/error-log")
public class ErrorLogController {
private final ErrorLogService service;
private final SystemAccessGuard guard;
public ErrorLogController(ErrorLogService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<PageResponse<ErrorLogDto>> list(
@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String errorCode,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.list(errorCode, page, size));
}
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.kintex.system.errorlog;
/** 오류 로그 행(조회). summary 는 1줄 요약(스택트레이스 미포함, §0-3). */
public record ErrorLogDto(
long id,
String errorCode,
String summary,
String path,
String method,
String actorId,
String createdAt
) {
}

View File

@ -0,0 +1,42 @@
package com.zioinfo.kintex.system.errorlog;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/** 오류 로그 매퍼(error_log). 요약 1줄만 저장(스택트레이스 금지). 테넌트 스코프. */
@Mapper
public interface ErrorLogMapper {
@Insert("INSERT INTO error_log (tenant_id, error_code, summary, path, method, actor_id) "
+ "VALUES (#{tenantId}, #{errorCode}, #{summary}, #{path}, #{method}, #{actorId})")
int insert(Map<String, Object> p);
@Select("""
<script>
SELECT id, error_code AS "errorCode", summary, path, method, actor_id AS "actorId",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
FROM error_log
<where>
AND tenant_id = #{tenantId}
<if test="errorCode != null and errorCode != ''">AND error_code = #{errorCode}</if>
</where>
ORDER BY id DESC
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(@Param("tenantId") String tenantId, @Param("errorCode") String errorCode,
@Param("size") int size, @Param("offset") int offset);
@Select("""
<script>
SELECT count(*) FROM error_log
<where>
AND tenant_id = #{tenantId}
<if test="errorCode != null and errorCode != ''">AND error_code = #{errorCode}</if>
</where>
</script>
""")
long count(@Param("tenantId") String tenantId, @Param("errorCode") String errorCode);
}

View File

@ -0,0 +1,65 @@
package com.zioinfo.kintex.system.errorlog;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.tenant.TenantContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 오류 로그 서비스 기록(best-effort)·조회. 저장은 요약 1줄만(스택트레이스·민감정보 금지, §0-3).
* 기록 실패가 예외 응답을 막지 않도록 예외를 삼킨다.
*/
@Service
public class ErrorLogService {
private static final Logger log = LoggerFactory.getLogger(ErrorLogService.class);
private final ErrorLogMapper mapper;
public ErrorLogService(ErrorLogMapper mapper) {
this.mapper = mapper;
}
/** 예외 요약 기록. summary 는 호출자가 스택 제외한 1줄 요약을 넘긴다. */
public void record(String errorCode, String summary, String path, String method, String actorId) {
try {
Map<String, Object> p = new HashMap<>();
p.put("tenantId", TenantContext.currentTenantId());
p.put("errorCode", errorCode);
p.put("summary", trunc(summary, 500));
p.put("path", trunc(path, 300));
p.put("method", method);
p.put("actorId", actorId);
mapper.insert(p);
} catch (Exception e) {
log.warn("[ERROR_LOG] record skipped: {}", e.getClass().getSimpleName());
}
}
public PageResponse<ErrorLogDto> list(String errorCode, int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 20 : Math.min(size, 200);
String tenantId = TenantContext.currentTenantId();
List<Map<String, Object>> rows = mapper.search(tenantId, errorCode, s, p * s);
long total = mapper.count(tenantId, errorCode);
return PageResponse.of(rows.stream().map(ErrorLogService::toDto).toList(), p, s, total);
}
private static ErrorLogDto toDto(Map<String, Object> r) {
return new ErrorLogDto(lng(r.get("id")), str(r.get("errorCode")), str(r.get("summary")),
str(r.get("path")), str(r.get("method")), str(r.get("actorId")), str(r.get("createdAt")));
}
private static String trunc(String v, int max) {
if (v == null) return null;
return v.length() <= max ? v : v.substring(0, max);
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static long lng(Object o) {
if (o instanceof Number n) return n.longValue();
return o == null ? 0L : Long.parseLong(String.valueOf(o));
}
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.kintex.system.infraconfig;
/** 설정 항목(메일/알림). secretYn='Y' 이면 settingValue 는 마스킹(********)되어 전달된다. */
public record InfraConfigDto(
String settingKey,
String settingValue,
String valueType,
String description,
String secretYn
) {
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.kintex.system.infraconfig;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.system.setting.SettingMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 메일/알림 설정 서비스 sys_setting mail. / notify. 프리픽스 항목을 조회/부분변경한다.
* 시크릿(secret_yn='Y') 응답 마스킹, 저장 마스킹 값은 미변경 처리(§0-3, 시크릿 미노출·미덮어쓰기).
*/
@Service
public class InfraConfigService {
private static final String MASK = "********";
private final SettingMapper mapper;
public InfraConfigService(SettingMapper mapper) {
this.mapper = mapper;
}
public List<InfraConfigDto> listByPrefix(String prefix) {
return mapper.findAll().stream()
.filter(r -> str(r.get("settingKey")) != null && str(r.get("settingKey")).startsWith(prefix))
.map(InfraConfigService::toMaskedDto)
.toList();
}
@Transactional
public void update(String prefix, InfraConfigUpdateRequest req, String actorId) {
if (req.items() == null) {
return;
}
for (InfraConfigUpdateRequest.Item it : req.items()) {
String key = it.settingKey();
if (key == null || !key.startsWith(prefix)) {
throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 설정 키입니다.");
}
Map<String, Object> existing = mapper.findByKey(key);
if (existing == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "설정 항목이 없습니다: " + key);
}
String secretYn = str(existing.get("secretYn"));
String value = it.settingValue();
// 마스킹 그대로 시크릿은 미변경(원문 보존).
if ("Y".equals(secretYn) && MASK.equals(value)) {
continue;
}
Map<String, Object> p = new HashMap<>();
p.put("settingKey", key);
p.put("settingValue", value);
p.put("valueType", str(existing.get("valueType")));
p.put("description", str(existing.get("description")));
p.put("secretYn", secretYn);
p.put("updatedBy", actorId);
mapper.upsert(p);
}
}
private static InfraConfigDto toMaskedDto(Map<String, Object> r) {
String secretYn = str(r.get("secretYn"));
String value = str(r.get("settingValue"));
if ("Y".equals(secretYn) && value != null && !value.isEmpty()) {
value = MASK;
}
return new InfraConfigDto(str(r.get("settingKey")), value, str(r.get("valueType")),
str(r.get("description")), secretYn);
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.kintex.system.infraconfig;
import java.util.List;
/** 메일/알림 설정 부분 변경 본문. 마스킹(********) 값은 미변경으로 간주(시크릿 보존). */
public record InfraConfigUpdateRequest(
List<Item> items
) {
public record Item(String settingKey, String settingValue) {
}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.kintex.system.infraconfig;
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.*;
import java.util.List;
/** 시스템관리 — 메일(SMTP) 설정 조회/변경 API. ADMIN 게이트. 시크릿 마스킹. */
@RestController
@RequestMapping("/api/admin/mail-config")
public class MailConfigController {
private static final String PREFIX = "mail.";
private final InfraConfigService service;
private final SystemAccessGuard guard;
public MailConfigController(InfraConfigService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<List<InfraConfigDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.listByPrefix(PREFIX));
}
@Audited(action = "MAIL_CONFIG_SAVE", targetType = "sys_setting")
@PutMapping
public ApiResponse<Void> update(@AuthenticationPrincipal KintexPrincipal principal,
@RequestBody InfraConfigUpdateRequest req) {
guard.requireAdmin(principal);
service.update(PREFIX, req, principal.userId());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.kintex.system.infraconfig;
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.*;
import java.util.List;
/** 시스템관리 — 알림 채널 설정 조회/변경 API. ADMIN 게이트. 채널 on/off. */
@RestController
@RequestMapping("/api/admin/notify-config")
public class NotifyConfigController {
private static final String PREFIX = "notify.";
private final InfraConfigService service;
private final SystemAccessGuard guard;
public NotifyConfigController(InfraConfigService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<List<InfraConfigDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.listByPrefix(PREFIX));
}
@Audited(action = "NOTIFY_CONFIG_SAVE", targetType = "sys_setting")
@PutMapping
public ApiResponse<Void> update(@AuthenticationPrincipal KintexPrincipal principal,
@RequestBody InfraConfigUpdateRequest req) {
guard.requireAdmin(principal);
service.update(PREFIX, req, principal.userId());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,33 @@
package com.zioinfo.kintex.system.loginhistory;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.system.SystemAccessGuard;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 시스템관리 — 로그인 이력(접속 세션) 조회 API. ADMIN 게이트. 읽기 전용. */
@RestController
@RequestMapping("/api/admin/login-history")
public class LoginHistoryController {
private final LoginHistoryService service;
private final SystemAccessGuard guard;
public LoginHistoryController(LoginHistoryService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<PageResponse<LoginHistoryDto>> list(
@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String result,
@RequestParam(required = false) String userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.list(result, userId, page, size));
}
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.kintex.system.loginhistory;
/** 로그인 이력 행(조회). 이메일/IP 는 마스킹되어 저장·전달된다(§0-3). */
public record LoginHistoryDto(
long id,
String userId,
String emailMasked,
String result,
String failReason,
String ipMasked,
String userAgent,
String createdAt
) {
}

View File

@ -0,0 +1,47 @@
package com.zioinfo.kintex.system.loginhistory;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/** 로그인 이력 매퍼(login_history). 저장 값은 마스킹만(원문 금지). 테넌트 스코프. */
@Mapper
public interface LoginHistoryMapper {
@Insert("INSERT INTO login_history (tenant_id, user_id, email_masked, result, fail_reason, ip_masked, user_agent) "
+ "VALUES (#{tenantId}, #{userId}, #{emailMasked}, #{result}, #{failReason}, #{ipMasked}, #{userAgent})")
int insert(Map<String, Object> p);
@Select("""
<script>
SELECT id, user_id AS "userId", email_masked AS "emailMasked", result,
fail_reason AS "failReason", ip_masked AS "ipMasked", user_agent AS "userAgent",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
FROM login_history
<where>
AND tenant_id = #{tenantId}
<if test="result != null and result != ''">AND result = #{result}</if>
<if test="userId != null and userId != ''">AND user_id = #{userId}</if>
</where>
ORDER BY id DESC
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(@Param("tenantId") String tenantId, @Param("result") String result,
@Param("userId") String userId,
@Param("size") int size, @Param("offset") int offset);
@Select("""
<script>
SELECT count(*) FROM login_history
<where>
AND tenant_id = #{tenantId}
<if test="result != null and result != ''">AND result = #{result}</if>
<if test="userId != null and userId != ''">AND user_id = #{userId}</if>
</where>
</script>
""")
long count(@Param("tenantId") String tenantId, @Param("result") String result,
@Param("userId") String userId);
}

View File

@ -0,0 +1,78 @@
package com.zioinfo.kintex.system.loginhistory;
import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.text.Masking;
import com.zioinfo.kintex.tenant.TenantContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 로그인 이력 서비스 기록(best-effort)·조회. 기록은 인증 흐름을 절대 깨지 않는다(예외 삼킴).
* 저장 값은 이메일/IP 마스킹만(원문 금지, §0-3).
*/
@Service
public class LoginHistoryService {
private static final Logger log = LoggerFactory.getLogger(LoginHistoryService.class);
private final LoginHistoryMapper mapper;
public LoginHistoryService(LoginHistoryMapper mapper) {
this.mapper = mapper;
}
/** 로그인 성공 기록(best-effort). ip 는 원문을 받아 마스킹 저장. */
public void recordSuccess(String userId, String email, String ip, String userAgent) {
record(userId, email, "SUCCESS", null, ip, userAgent);
}
/** 로그인 실패 기록(best-effort). reason 은 짧은 요약 코드. */
public void recordFailure(String email, String reason, String ip, String userAgent) {
record(null, email, "FAIL", reason, ip, userAgent);
}
private void record(String userId, String email, String result, String reason, String ip, String userAgent) {
try {
Map<String, Object> p = new HashMap<>();
p.put("tenantId", TenantContext.currentTenantId());
p.put("userId", userId);
p.put("emailMasked", Masking.maskEmail(email));
p.put("result", result);
p.put("failReason", reason);
p.put("ipMasked", Masking.maskIp(ip));
p.put("userAgent", trunc(userAgent, 300));
mapper.insert(p);
} catch (Exception e) {
// 이력 기록 실패가 로그인 자체를 막아선 된다.
log.warn("[LOGIN_HISTORY] record skipped: {}", e.getClass().getSimpleName());
}
}
public PageResponse<LoginHistoryDto> list(String result, String userId, int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 20 : Math.min(size, 200);
String tenantId = TenantContext.currentTenantId();
List<Map<String, Object>> rows = mapper.search(tenantId, result, userId, s, p * s);
long total = mapper.count(tenantId, result, userId);
return PageResponse.of(rows.stream().map(LoginHistoryService::toDto).toList(), p, s, total);
}
private static LoginHistoryDto toDto(Map<String, Object> r) {
return new LoginHistoryDto(
lng(r.get("id")), str(r.get("userId")), str(r.get("emailMasked")), str(r.get("result")),
str(r.get("failReason")), str(r.get("ipMasked")), str(r.get("userAgent")), str(r.get("createdAt")));
}
private static String trunc(String v, int max) {
if (v == null) return null;
return v.length() <= max ? v : v.substring(0, max);
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static long lng(Object o) {
if (o instanceof Number n) return n.longValue();
return o == null ? 0L : Long.parseLong(String.valueOf(o));
}
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.kintex.system.program;
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 jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 시스템관리 — 프로그램(화면) 등록부 API. ADMIN 게이트. */
@RestController
@RequestMapping("/api/admin/programs")
public class ProgramController {
private final ProgramService service;
private final SystemAccessGuard guard;
public ProgramController(ProgramService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<List<ProgramDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.list(keyword, category));
}
@Audited(action = "PROGRAM_SAVE", targetType = "sys_program")
@PostMapping
public ApiResponse<Void> save(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody ProgramSaveRequest req) {
guard.requireAdmin(principal);
service.save(req);
return ApiResponse.ok(null);
}
@Audited(action = "PROGRAM_DELETE", targetType = "sys_program")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.requireAdmin(principal);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,12 @@
package com.zioinfo.kintex.system.program;
/** 프로그램(화면) 등록부 항목. */
public record ProgramDto(
String id,
String programName,
String programType,
String programUrl,
String category,
String useYn
) {
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.kintex.system.program;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/** 프로그램(화면) 매퍼(sys_program). 테넌트 스코프. */
@Mapper
public interface ProgramMapper {
@Select("""
<script>
SELECT id, program_name AS "programName", program_type AS "programType",
program_url AS "programUrl", category, use_yn AS "useYn"
FROM sys_program
<where>
AND tenant_id = #{tenantId}
<if test="keyword != null and keyword != ''">
AND (program_name ILIKE '%'||#{keyword}||'%' OR program_url ILIKE '%'||#{keyword}||'%')
</if>
<if test="category != null and category != ''">AND category = #{category}</if>
</where>
ORDER BY category NULLS FIRST, program_name
</script>
""")
List<Map<String, Object>> findAll(@Param("tenantId") String tenantId,
@Param("keyword") String keyword, @Param("category") String category);
@Insert("INSERT INTO sys_program (tenant_id, id, program_name, program_type, program_url, category, use_yn) "
+ "VALUES (#{tenantId}, #{id}, #{programName}, COALESCE(#{programType},'SCREEN'), #{programUrl}, "
+ "#{category}, COALESCE(#{useYn},'Y')) "
+ "ON CONFLICT (tenant_id, id) DO UPDATE SET program_name=EXCLUDED.program_name, "
+ "program_type=EXCLUDED.program_type, program_url=EXCLUDED.program_url, "
+ "category=EXCLUDED.category, use_yn=EXCLUDED.use_yn, updated_at=now()")
int upsert(Map<String, Object> p);
@Delete("DELETE FROM sys_program WHERE tenant_id = #{tenantId} AND id = #{id}")
int delete(@Param("tenantId") String tenantId, @Param("id") String id);
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.kintex.system.program;
import jakarta.validation.constraints.NotBlank;
/** 프로그램 저장 본문. id 가 있으면 수정, 없으면 신규. */
public record ProgramSaveRequest(
String id,
@NotBlank String programName,
String programType,
String programUrl,
String category,
String useYn
) {
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.kintex.system.program;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.tenant.TenantContext;
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;
/** 프로그램(화면) 등록부 서비스. 테넌트 스코프. */
@Service
public class ProgramService {
private final ProgramMapper mapper;
public ProgramService(ProgramMapper mapper) {
this.mapper = mapper;
}
public List<ProgramDto> list(String keyword, String category) {
return mapper.findAll(TenantContext.currentTenantId(), keyword, category).stream()
.map(ProgramService::toDto).toList();
}
@Transactional
public void save(ProgramSaveRequest req) {
String id = (req.id() == null || req.id().isBlank())
? "prg-" + UUID.randomUUID().toString().substring(0, 12) : req.id();
Map<String, Object> p = new HashMap<>();
p.put("tenantId", TenantContext.currentTenantId());
p.put("id", id);
p.put("programName", req.programName());
p.put("programType", req.programType());
p.put("programUrl", req.programUrl());
p.put("category", req.category());
p.put("useYn", req.useYn());
mapper.upsert(p);
}
@Transactional
public void delete(String id) {
if (mapper.delete(TenantContext.currentTenantId(), id) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
}
private static ProgramDto toDto(Map<String, Object> r) {
return new ProgramDto(str(r.get("id")), str(r.get("programName")), str(r.get("programType")),
str(r.get("programUrl")), str(r.get("category")), str(r.get("useYn")));
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.kintex.system.rolemenu;
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.*;
import java.util.List;
/** 시스템관리 — 역할별 메뉴 접근 매핑 API. ADMIN 게이트. 기존 역할/권한(rolePerm)과 직교. */
@RestController
@RequestMapping("/api/admin/roles/{roleCode}/menus")
public class RoleMenuController {
private final RoleMenuService service;
private final SystemAccessGuard guard;
public RoleMenuController(RoleMenuService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<List<RoleMenuDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String roleCode) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.listByRole(roleCode));
}
@Audited(action = "ROLE_MENU_SAVE", targetType = "sys_role_menu")
@PostMapping
public ApiResponse<Void> save(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String roleCode,
@RequestBody RoleMenuSaveRequest req) {
guard.requireAdmin(principal);
service.save(roleCode, req);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.kintex.system.rolemenu;
/** 역할-메뉴 매핑 행. mapped=false 면 아직 미부여(read/write 기본값). */
public record RoleMenuDto(
String menuId,
String menuName,
String parentId,
Integer sortOrder,
boolean mapped,
String readYn,
String writeYn
) {
}

View File

@ -0,0 +1,33 @@
package com.zioinfo.kintex.system.rolemenu;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/** 역할-메뉴 매핑 매퍼(sys_role_menu). 전역 규약(sys_menu·sys_role 참조). */
@Mapper
public interface RoleMenuMapper {
/** 전체 메뉴 + 해당 역할의 매핑 상태(LEFT JOIN). */
@Select("SELECT m.id AS \"menuId\", m.menu_name AS \"menuName\", m.parent_id AS \"parentId\", "
+ "m.sort_order AS \"sortOrder\", "
+ "(rm.menu_id IS NOT NULL) AS \"mapped\", "
+ "COALESCE(rm.read_yn,'N') AS \"readYn\", COALESCE(rm.write_yn,'N') AS \"writeYn\" "
+ "FROM sys_menu m "
+ "LEFT JOIN sys_role_menu rm ON rm.menu_id = m.id AND rm.role_code = #{roleCode} "
+ "ORDER BY m.sort_order, m.id")
List<Map<String, Object>> findByRole(@Param("roleCode") String roleCode);
@Delete("DELETE FROM sys_role_menu WHERE role_code = #{roleCode}")
int clear(@Param("roleCode") String roleCode);
@Insert("INSERT INTO sys_role_menu (role_code, menu_id, read_yn, write_yn) "
+ "VALUES (#{roleCode}, #{menuId}, COALESCE(#{readYn},'Y'), COALESCE(#{writeYn},'N')) "
+ "ON CONFLICT (role_code, menu_id) DO UPDATE SET read_yn=EXCLUDED.read_yn, write_yn=EXCLUDED.write_yn")
int add(@Param("roleCode") String roleCode, @Param("menuId") String menuId,
@Param("readYn") String readYn, @Param("writeYn") String writeYn);
@Select("SELECT COUNT(*) FROM sys_role WHERE role_code = #{roleCode}")
int roleExists(@Param("roleCode") String roleCode);
}

View File

@ -0,0 +1,12 @@
package com.zioinfo.kintex.system.rolemenu;
import java.util.List;
/** 역할별 메뉴 매핑 저장 본문. 목록에 없는 메뉴는 매핑 해제된다(전체 치환). */
public record RoleMenuSaveRequest(
List<Entry> menus
) {
/** 매핑 항목. readYn/writeYn 미지정 시 read=Y·write=N. */
public record Entry(String menuId, String readYn, String writeYn) {
}
}

View File

@ -0,0 +1,62 @@
package com.zioinfo.kintex.system.rolemenu;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
/** 역할-메뉴 매핑 서비스. 저장은 전체 치환(clear→insert). */
@Service
public class RoleMenuService {
private final RoleMenuMapper mapper;
public RoleMenuService(RoleMenuMapper mapper) {
this.mapper = mapper;
}
public List<RoleMenuDto> listByRole(String roleCode) {
if (mapper.roleExists(roleCode) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "역할을 찾을 수 없습니다.");
}
return mapper.findByRole(roleCode).stream().map(RoleMenuService::toDto).toList();
}
@Transactional
public void save(String roleCode, RoleMenuSaveRequest req) {
if (mapper.roleExists(roleCode) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "역할을 찾을 수 없습니다.");
}
mapper.clear(roleCode);
if (req.menus() != null) {
for (RoleMenuSaveRequest.Entry e : req.menus()) {
if (e.menuId() == null || e.menuId().isBlank()) {
continue;
}
mapper.add(roleCode, e.menuId(),
norm(e.readYn(), "Y"), norm(e.writeYn(), "N"));
}
}
}
private static String norm(String v, String def) {
return "Y".equalsIgnoreCase(v) ? "Y" : ("N".equalsIgnoreCase(v) ? "N" : def);
}
private static RoleMenuDto toDto(Map<String, Object> r) {
return new RoleMenuDto(str(r.get("menuId")), str(r.get("menuName")), str(r.get("parentId")),
intv(r.get("sortOrder")), bool(r.get("mapped")), str(r.get("readYn")), str(r.get("writeYn")));
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static Integer intv(Object o) {
if (o instanceof Number n) return n.intValue();
return o == null ? 0 : Integer.parseInt(String.valueOf(o));
}
private static boolean bool(Object o) {
if (o instanceof Boolean b) return b;
return o != null && Boolean.parseBoolean(String.valueOf(o));
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.kintex.system.syshealth;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.system.SystemAccessGuard;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 시스템관리 — 시스템 상태 대시보드 API. ADMIN 게이트(공개 /health 와 별개, 확장 지표). */
@RestController
@RequestMapping("/api/admin/system-health")
public class SystemHealthController {
private final SystemHealthService service;
private final SystemAccessGuard guard;
public SystemHealthController(SystemHealthService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
@GetMapping
public ApiResponse<SystemHealthDto> health(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.health());
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.kintex.system.syshealth;
/**
* 시스템 상태 요약(운영 대시보드). 민감정보(내부IP·스택트레이스) 미노출.
*
* @param db DB 연결 UP/DOWN
* @param redis Redis 연결 UP/DOWN
* @param worker 렌더 워커 상태 UP/IDLE/DOWN(추정: 접근 가능 여부)
* @param queueDepth 렌더 작업 적재 (측정 불가 -1)
* @param diskFreePct 디스크 여유 비율(%)
* @param diskFreeMb 디스크 여유 용량(MB)
* @param memUsedPct 메모리 사용률(%)
* @param uptimeSec 가동 시간()
* @param version 애플리케이션 버전
* @param time 서버 시각(ISO)
*/
public record SystemHealthDto(
String db,
String redis,
String worker,
long queueDepth,
long diskFreePct,
long diskFreeMb,
long memUsedPct,
long uptimeSec,
String version,
String time
) {
}

View File

@ -0,0 +1,114 @@
package com.zioinfo.kintex.system.syshealth;
import com.zioinfo.kintex.module.m5.RenderProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.io.File;
import java.lang.management.ManagementFactory;
import java.time.OffsetDateTime;
/**
* 시스템 상태 집계(actuator 미도입, 의존성 0 커스텀) ADMIN 전용.
* DB=경량 SELECT 1, Redis=ping, worker=렌더 접근 가능 여부·적재량, 디스크/메모리=JVM 표준.
* 스택트레이스·내부IP 미노출.
*/
@Service
public class SystemHealthService {
private static final Logger log = LoggerFactory.getLogger(SystemHealthService.class);
private static final String VERSION = "1.0.0";
private final JdbcTemplate jdbcTemplate;
private final StringRedisTemplate redis;
private final RenderProperties renderProps;
public SystemHealthService(JdbcTemplate jdbcTemplate, StringRedisTemplate redis,
RenderProperties renderProps) {
this.jdbcTemplate = jdbcTemplate;
this.redis = redis;
this.renderProps = renderProps;
}
public SystemHealthDto health() {
boolean redisUp = redisUp();
long depth = redisUp ? queueDepth() : -1L;
return new SystemHealthDto(
dbStatus(),
redisUp ? "UP" : "DOWN",
workerStatus(redisUp, depth),
depth,
diskFreePct(),
diskFreeMb(),
memUsedPct(),
uptimeSec(),
VERSION,
OffsetDateTime.now().toString());
}
private String dbStatus() {
try {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
return "UP";
} catch (Exception e) {
log.warn("[HEALTH] DB DOWN: {}", e.getClass().getSimpleName());
return "DOWN";
}
}
private boolean redisUp() {
org.springframework.data.redis.connection.RedisConnectionFactory factory = redis.getConnectionFactory();
if (factory == null) {
return false;
}
try (org.springframework.data.redis.connection.RedisConnection conn = factory.getConnection()) {
String pong = conn.ping();
return pong != null;
} catch (Exception e) {
log.warn("[HEALTH] Redis DOWN: {}", e.getClass().getSimpleName());
return false;
}
}
private long queueDepth() {
try {
Long size = redis.opsForList().size(renderProps.getQueueKey());
return size == null ? 0L : size;
} catch (Exception e) {
return -1L;
}
}
/** 워커 상태(추정): Redis 접근 불가=DOWN, 큐 비어있음=IDLE, 적재 있음=UP. */
private String workerStatus(boolean redisUp, long depth) {
if (!redisUp || depth < 0) {
return "DOWN";
}
return depth == 0 ? "IDLE" : "UP";
}
private long diskFreePct() {
File root = new File(".");
long total = root.getTotalSpace();
long usable = root.getUsableSpace();
return total <= 0 ? -1 : Math.round(usable * 100.0 / total);
}
private long diskFreeMb() {
return new File(".").getUsableSpace() / (1024 * 1024);
}
private long memUsedPct() {
Runtime rt = Runtime.getRuntime();
long used = rt.totalMemory() - rt.freeMemory();
long max = rt.maxMemory();
return max <= 0 ? -1 : Math.round(used * 100.0 / max);
}
private long uptimeSec() {
return ManagementFactory.getRuntimeMXBean().getUptime() / 1000;
}
}

View File

@ -34,6 +34,19 @@ spring:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
# 메일(자체 Postfix SMTP — mail.zioinfo.co.kr, GUARDiA 운영). 자격증명은 env 주입만(하드코딩 금지).
# 실 발송은 MAIL_ENABLED=true 일 때만. 미주입 시 인앱/스킵 로그만 남긴다(정직 로그).
mail:
host: ${SMTP_HOST:mail.zioinfo.co.kr}
port: ${SMTP_PORT:587}
username: ${SMTP_USER:}
password: ${SMTP_PASSWORD:}
properties:
mail.smtp.auth: ${SMTP_AUTH:true}
mail.smtp.starttls.enable: ${SMTP_STARTTLS:true}
mail.smtp.connectiontimeout: 8000
mail.smtp.timeout: 8000
mail.smtp.writetimeout: 8000
# Flyway — 순번 DDL/시드가 스키마의 권위(sql.init 미사용, 후행 테이블 미적용 함정 회피). # Flyway — 순번 DDL/시드가 스키마의 권위(sql.init 미사용, 후행 테이블 미적용 함정 회피).
flyway: flyway:
enabled: ${FLYWAY_ENABLED:true} enabled: ${FLYWAY_ENABLED:true}
@ -69,6 +82,12 @@ kintex:
email: ${ADMIN_EMAIL:} email: ${ADMIN_EMAIL:}
password: ${ADMIN_PASSWORD:} password: ${ADMIN_PASSWORD:}
display-name: ${ADMIN_DISPLAY_NAME:시스템관리자} display-name: ${ADMIN_DISPLAY_NAME:시스템관리자}
# 메일 발송 채널(옥션 개설 통지·EDM·비밀번호 재설정). enabled=false면 인앱/스킵 로그만(실 전송 없음).
mail:
enabled: ${MAIL_ENABLED:false}
from: ${MAIL_FROM:no-reply@kintex.zioinfo.co.kr}
# 재설정 링크의 프론트 베이스 URL — 링크는 코드 안내용(프론트 reset 흐름은 이메일+코드 입력).
web-base-url: ${WEB_BASE_URL:https://kintex.zioinfo.co.kr}
rules: rules:
# 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응) # 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응)
compliance-ruleset: classpath:rulesets/compliance-v1.json compliance-ruleset: classpath:rulesets/compliance-v1.json

View File

@ -0,0 +1,20 @@
-- V36: 메일 발송 이력(mail_log) — 자체 Postfix SMTP 채널(옥션 개설 통지·EDM·비밀번호 재설정).
-- 소유자 승인(2026-07-12): 자체 인프라 SMTP(mail.zioinfo.co.kr) 연결. 외부 SaaS 아님.
-- 불변: V1~V35 파일 불변. 본 마이그레이션은 순증(additive)·멱등만 수행한다.
-- 보안 불변(§0-3): 본문·SMTP 자격증명은 저장하지 않는다. 수신자는 마스킹(kim***@do.com)만 저장.
-- 테넌트 표준(V31): tenant_id DEFAULT 'KINTEX' + 복합 PK (tenant_id, id).
CREATE TABLE IF NOT EXISTS mail_log (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id varchar(40) NOT NULL,
category varchar(30) NOT NULL, -- AUCTION_OPEN|EDM|PASSWORD_RESET
ref_id varchar(40), -- 연관 대상(auction_id·campaign_id 등)
subject varchar(300) NOT NULL, -- 제목만(본문 미저장)
recipient_masked varchar(200) NOT NULL, -- 마스킹 수신자(원문 저장 금지)
status varchar(20) NOT NULL DEFAULT 'sent', -- sent|failed|skipped
detail varchar(300), -- 요약(실패 사유 요약 — 스택트레이스 금지)
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_mail_log_category ON mail_log (tenant_id, category, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_mail_log_ref ON mail_log (tenant_id, ref_id);

View File

@ -0,0 +1,171 @@
-- V37 시스템관리 기본기 이식 1차 (WISE/UIWS system.* 이식 — 소유자 지적 2026-07-12)
-- 목표: 조직(부서/회사)·프로그램·역할-메뉴 매핑·인증정책·로그인이력·오류로그 기본 테이블.
-- 불변: V1~V36 파일 불변. 본 마이그레이션은 순증(additive)·멱등만 수행한다(단일 트랜잭션).
-- 테넌트 표준(V31): 엔티티 테이블은 tenant_id DEFAULT 'KINTEX' + 복합 PK (tenant_id, id) 선두.
-- 전역 매핑/설정(role-menu 등 sys_role·sys_menu 참조)은 기존 sys_role_permission 규약과 정합(테넌트 미부여).
-- 보안 불변(§0-3): 비밀번호·시크릿·스택트레이스 미저장. 이메일/IP는 마스킹만 저장.
-- ============================================================================
-- 1) 조직 — 부서(dept) 트리 · 회사(company)
-- ============================================================================
CREATE TABLE IF NOT EXISTS dept (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id varchar(40) NOT NULL,
dept_name varchar(120) NOT NULL,
parent_id varchar(40),
sort_order integer NOT NULL DEFAULT 0,
use_yn char(1) NOT NULL DEFAULT 'Y',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_dept_parent ON dept (tenant_id, parent_id, sort_order);
CREATE TABLE IF NOT EXISTS company (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id varchar(40) NOT NULL,
company_name varchar(200) NOT NULL,
biz_no varchar(40),
ceo_name varchar(120),
biz_type varchar(120),
contact_name varchar(120),
contact_tel varchar(60), -- 저장은 원문, 응답은 마스킹(코드 계층)
address varchar(400),
partner_type varchar(40), -- 공통코드(PARTNER_TYPE) 값
use_yn char(1) NOT NULL DEFAULT 'Y',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_company_name ON company (tenant_id, company_name);
-- ============================================================================
-- 2) 프로그램(화면) 등록부 — 메뉴와 연결되는 라우트/화면 마스터
-- ============================================================================
CREATE TABLE IF NOT EXISTS sys_program (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id varchar(40) NOT NULL,
program_name varchar(160) NOT NULL,
program_type varchar(20) NOT NULL DEFAULT 'SCREEN', -- SCREEN|POPUP|LINK
program_url varchar(300), -- 라우트 경로(/admin/users 등)
category varchar(80),
use_yn char(1) NOT NULL DEFAULT 'Y',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_sys_program_cat ON sys_program (tenant_id, category, program_name);
-- ============================================================================
-- 3) 역할-메뉴 매핑 — 역할별 메뉴 접근(read/write). sys_role·sys_menu 참조(전역 규약).
-- ============================================================================
CREATE TABLE IF NOT EXISTS sys_role_menu (
role_code varchar(20) NOT NULL REFERENCES sys_role(role_code) ON DELETE CASCADE,
menu_id varchar(40) NOT NULL REFERENCES sys_menu(id) ON DELETE CASCADE,
read_yn char(1) NOT NULL DEFAULT 'Y',
write_yn char(1) NOT NULL DEFAULT 'N',
PRIMARY KEY (role_code, menu_id)
);
CREATE INDEX IF NOT EXISTS idx_sys_role_menu_role ON sys_role_menu (role_code);
-- ============================================================================
-- 4) 인증 정책 — 비밀번호 정책·로그인 잠금 임계(테넌트 싱글턴 1행)
-- 기존 LoginAttemptService/sys_setting(login.*) 값을 정책화(코드 계층은 sys_setting 유지·본 표는 관리 조회).
-- ============================================================================
CREATE TABLE IF NOT EXISTS sys_auth_policy (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
pw_min_length integer NOT NULL DEFAULT 8,
pw_require_upper char(1) NOT NULL DEFAULT 'N',
pw_require_number char(1) NOT NULL DEFAULT 'Y',
pw_require_special char(1) NOT NULL DEFAULT 'N',
pw_expire_days integer NOT NULL DEFAULT 0, -- 0 = 만료 없음
login_max_fail integer NOT NULL DEFAULT 5,
lock_minutes integer NOT NULL DEFAULT 15,
session_timeout_min integer NOT NULL DEFAULT 120,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(40),
PRIMARY KEY (tenant_id)
);
INSERT INTO sys_auth_policy (tenant_id) VALUES ('KINTEX')
ON CONFLICT (tenant_id) DO NOTHING;
-- ============================================================================
-- 5) 로그인 이력(신설) — 성공/실패. 이메일/IP 마스킹만 저장(원문 금지).
-- ============================================================================
CREATE TABLE IF NOT EXISTS login_history (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id bigserial NOT NULL,
user_id varchar(40),
email_masked varchar(200), -- kim***@do.com
result varchar(20) NOT NULL DEFAULT 'SUCCESS', -- SUCCESS|FAIL|LOCKED
fail_reason varchar(60), -- BAD_CREDENTIAL|LOCKED (요약)
ip_masked varchar(60), -- 203.0.113.* (원문 금지)
user_agent varchar(300),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_login_history_created ON login_history (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_history_user ON login_history (tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_login_history_result ON login_history (tenant_id, result, created_at DESC);
-- ============================================================================
-- 6) 오류 로그(신설) — GlobalExceptionHandler 500 요약 1줄(스택트레이스 미저장).
-- ============================================================================
CREATE TABLE IF NOT EXISTS error_log (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
id bigserial NOT NULL,
error_code varchar(60), -- INTERNAL 등
summary varchar(500), -- 예외 클래스 + 요약(민감정보·스택 제외)
path varchar(300),
method varchar(10),
actor_id varchar(40),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_error_log_created ON error_log (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_error_log_code ON error_log (tenant_id, error_code, created_at DESC);
-- ============================================================================
-- 7) 공통코드 순증 — PARTNER_TYPE(회사 분류)
-- ============================================================================
INSERT INTO common_code_group (grp_code, grp_name, description, sort_order) VALUES
('PARTNER_TYPE', '거래처 분류', 'company 이식', 20)
ON CONFLICT (grp_code) DO UPDATE SET grp_name = EXCLUDED.grp_name, description = EXCLUDED.description;
INSERT INTO common_code (grp_code, code, code_name, sort_order) VALUES
('PARTNER_TYPE','CONTRACTOR','장치업체',1),
('PARTNER_TYPE','SUPPLIER','공급업체',2),
('PARTNER_TYPE','AGENCY','대행사',3),
('PARTNER_TYPE','ETC','기타',4)
ON CONFLICT (grp_code, code) DO UPDATE SET code_name = EXCLUDED.code_name, sort_order = EXCLUDED.sort_order;
-- ============================================================================
-- 8) 시스템관리 메뉴 순증 — 신규 화면(조직·프로그램·역할메뉴·인증정책·로그인이력·오류로그·시스템상태·메일/알림)
-- ============================================================================
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, icon, sort_order, required_role) VALUES
('m-sys-dept', 'm-sys', '조직 관리', '/admin/depts', 'sitemap', 10, 'ADMIN'),
('m-sys-comp', 'm-sys', '회사 관리', '/admin/companies', 'building', 11, 'ADMIN'),
('m-sys-prog', 'm-sys', '프로그램 관리','/admin/programs', 'window', 12, 'ADMIN'),
('m-sys-rmenu', 'm-sys', '역할-메뉴', '/admin/role-menus', 'grid', 13, 'ADMIN'),
('m-sys-authp', 'm-sys', '인증 정책', '/admin/auth-policy', 'lock', 14, 'ADMIN'),
('m-sys-login', 'm-sys', '로그인 이력', '/admin/login-history','log-in', 15, 'ADMIN'),
('m-sys-errlog', 'm-sys', '오류 로그', '/admin/error-log', 'alert', 16, 'ADMIN'),
('m-sys-health', 'm-sys', '시스템 상태', '/admin/system-health','activity', 17, 'ADMIN'),
('m-sys-mail', 'm-sys', '메일 설정', '/admin/mail-config', 'mail', 18, 'ADMIN'),
('m-sys-notify', 'm-sys', '알림 설정', '/admin/notify-config','bell', 19, 'ADMIN')
ON CONFLICT (id) DO UPDATE SET menu_name = EXCLUDED.menu_name, menu_path = EXCLUDED.menu_path,
icon = EXCLUDED.icon, sort_order = EXCLUDED.sort_order, required_role = EXCLUDED.required_role;
-- ============================================================================
-- 9) 메일/알림 설정 기본값(sys_setting) — mail.* / notify.* (시크릿은 secret_yn='Y')
-- ============================================================================
INSERT INTO sys_setting (setting_key, setting_value, value_type, description, secret_yn) VALUES
('mail.enabled', 'true', 'BOOL', '메일 발송 사용', 'N'),
('mail.host', 'mail.zioinfo.co.kr', 'STRING', 'SMTP 호스트', 'N'),
('mail.port', '587', 'INT', 'SMTP 포트', 'N'),
('mail.fromAddress', 'noreply@kintex.co.kr', 'STRING', '발신 주소', 'N'),
('mail.fromName', 'KINTEX', 'STRING', '발신자 표기', 'N'),
('mail.password', '', 'STRING', 'SMTP 비밀번호(env 주입 권장)', 'Y'),
('notify.email.enabled', 'true', 'BOOL', '이메일 알림 채널', 'N'),
('notify.inapp.enabled', 'true', 'BOOL', '인앱 알림 채널', 'N'),
('notify.webhook.enabled', 'false', 'BOOL', '웹훅 알림 채널', 'N')
ON CONFLICT (setting_key) DO NOTHING;

View File

@ -0,0 +1,546 @@
-- ============================================================================
-- V38 마스터 + 데모 폐루프 데이터 전면 시드 (킨텍스 리뉴얼 Phase 2)
-- 담당: kintex-testdata-dev · 작성: 2026-07-12
-- ----------------------------------------------------------------------------
-- 원칙(불변):
-- • V1~V37 파일 불변. 본 파일은 순증(additive)만 수행하며 전부 순수 INSERT ... ON CONFLICT DO NOTHING.
-- (기존 시드/실데이터 UPDATE·DELETE 절대 금지 — 재실행·재배포 안전.)
-- • 테넌트 표준 = 'KINTEX'(대문자, V31 정규화). tenant_id 보유 테이블만 명시 세팅.
-- • 폐루프 축 = 기존 행사 e-2026-smf(2026 스마트팩토리 코리아, 개장 2026-08-11).
-- • PII(이름/전화/이메일)는 전부 가짜 더미(@kintex.test · 010-0000-xxxx 계열). 실존 인물 금지.
-- • 마감/할 일 활성화 날짜는 now()/CURRENT_DATE 상대값(배포 시점 기준으로 항상 유효).
-- 홈 할 일(HomeMapper) 활성화 대상:
-- 결재 대기(approval_line.CURRENT), 서류 D-3(required_document due=CURRENT_DATE+3),
-- 옥션 낙찰 선정(마감 경과+낙찰 미확정 AUC-2026-0511), 응찰 마감 임박(AUC-2026-0102 D-2),
-- 미수 청구(invoice pending/overdue), 오늘 체크인(visitor_registration.checkin_at::date=today).
-- ============================================================================
-- ============================================================================
-- 1. 마스터 데이터
-- ============================================================================
-- 1-1) 공통코드 — 실사용 그룹(부스 타입·서류 상태·옥션 카테고리·업종) --------------
INSERT INTO common_code_group (grp_code, grp_name, description, sort_order) VALUES
('BOOTH_TYPE', '부스 타입', 'booth.booth_type — assembled|independent|corner|island', 30),
('DOC_STATUS', '서류 상태', 'required_document.status', 31),
('AUCTION_CATEGORY', '옥션 카테고리', 'auction.category — 공사/장치 발주 분류', 32),
('INDUSTRY', '업종', '참가업체/리드 업종 분류', 33),
('INVOICE_STATUS', '청구 상태', 'invoice.status', 34),
('BOOTH_SALE_STATUS','부스 판매상태', 'booth_sale.status', 35)
ON CONFLICT (grp_code) DO NOTHING;
INSERT INTO common_code (grp_code, code, code_name, sort_order) VALUES
('BOOTH_TYPE','assembled','조립부스',1),
('BOOTH_TYPE','independent','독립부스',2),
('BOOTH_TYPE','corner','코너부스',3),
('BOOTH_TYPE','island','아일랜드부스',4),
('DOC_STATUS','pending','대기',1),
('DOC_STATUS','draft','작성중',2),
('DOC_STATUS','submitted','제출',3),
('DOC_STATUS','approved','승인',4),
('DOC_STATUS','rejected','반려',5),
('AUCTION_CATEGORY','전시디자인설치','전시디자인설치',1),
('AUCTION_CATEGORY','구조물 임대','구조물 임대',2),
('AUCTION_CATEGORY','전기/조명','전기/조명',3),
('AUCTION_CATEGORY','영상/음향','영상/음향',4),
('AUCTION_CATEGORY','네트워크','네트워크',5),
('INDUSTRY','MFG','제조/자동화',1),
('INDUSTRY','ROBOT','로봇',2),
('INDUSTRY','ELEC','전기/전자',3),
('INDUSTRY','LOGI','물류/유통',4),
('INDUSTRY','ICT','ICT/소프트웨어',5),
('INDUSTRY','BIO','바이오/헬스',6),
('INDUSTRY','ETC','기타',7),
('INVOICE_STATUS','pending','미발행',1),
('INVOICE_STATUS','invoiced','발행',2),
('INVOICE_STATUS','paid','완납',3),
('INVOICE_STATUS','overdue','연체',4),
('INVOICE_STATUS','cancelled','취소',5),
('BOOTH_SALE_STATUS','available','판매가능',1),
('BOOTH_SALE_STATUS','held','선점',2),
('BOOTH_SALE_STATUS','sold','판매완료',3),
('BOOTH_SALE_STATUS','blocked','판매불가',4)
ON CONFLICT (grp_code, code) DO NOTHING;
-- 1-2) 프로그램 등록부(sys_program) — App.tsx 라우트 스캔·화면 등록 -----------------
INSERT INTO sys_program (tenant_id, id, program_name, program_type, program_url, category) VALUES
('KINTEX','pg-home','메인 홈','SCREEN','/home','공통'),
('KINTEX','pg-login','로그인','SCREEN','/login','인증'),
('KINTEX','pg-otp','OTP 설정','SCREEN','/otp-setup','인증'),
('KINTEX','pg-dashboard','행사 대시보드','SCREEN','/events/:eventId/dashboard','부스코어'),
('KINTEX','pg-booth-home','부스 홈','SCREEN','/events/:eventId/booths/:boothId/home','부스코어'),
('KINTEX','pg-layout','홀 배치 에디터','SCREEN','/events/:eventId/halls/:hallId/layout','부스코어'),
('KINTEX','pg-comparison','배치안 비교','SCREEN','/events/:eventId/halls/:hallId/comparison','부스코어'),
('KINTEX','pg-design','부스 설계','SCREEN','/events/:eventId/booths/:boothId/design','부스코어'),
('KINTEX','pg-utility','유틸리티','SCREEN','/events/:eventId/booths/:boothId/utility','부스코어'),
('KINTEX','pg-utility-order','유틸리티 신청','SCREEN','/events/:eventId/booths/:boothId/utility/order','부스코어'),
('KINTEX','pg-compliance','규정 검토','SCREEN','/events/:eventId/booths/:boothId/compliance','부스코어'),
('KINTEX','pg-review','도면 검수','SCREEN','/events/:eventId/halls/:hallId/booths/:booth/review','부스코어'),
('KINTEX','pg-gallery','AI 갤러리','SCREEN','/events/:eventId/booths/:boothId/gallery','부스코어'),
('KINTEX','pg-booth-sales','부스 판매','SCREEN','/booth-sales','부스코어'),
('KINTEX','pg-halls','전시장/홀','SCREEN','/halls','부스코어'),
('KINTEX','pg-analytics','경영분석/BI','SCREEN','/analytics','도메인'),
('KINTEX','pg-ops','운영 관제','SCREEN','/ops/operations','도메인'),
('KINTEX','pg-docs','신고서류','SCREEN','/docs','도메인'),
('KINTEX','pg-docs-authoring','서류 작성','SCREEN','/docs/authoring','도메인'),
('KINTEX','pg-logistics','물류/반입반출','SCREEN','/logistics','도메인'),
('KINTEX','pg-auctions','공사 옥션','SCREEN','/auctions','도메인'),
('KINTEX','pg-auction-detail','옥션 상세','SCREEN','/auctions/:auctionId','도메인'),
('KINTEX','pg-auction-award','옥션 낙찰선정','SCREEN','/auctions/:auctionId/award','도메인'),
('KINTEX','pg-contractor','공사업체 대시보드','SCREEN','/contractor/dashboard','도메인'),
('KINTEX','pg-visitors','관람객 등록','SCREEN','/visitors','도메인'),
('KINTEX','pg-checkin','현장 체크인','SCREEN','/visitors/checkin','도메인'),
('KINTEX','pg-leads','리드 스코어링','SCREEN','/leads','도메인'),
('KINTEX','pg-campaigns','EDM 캠페인','SCREEN','/campaigns','도메인'),
('KINTEX','pg-sponsorship','스폰서십','SCREEN','/sponsorship','도메인'),
('KINTEX','pg-cms','CMS','SCREEN','/cms','도메인'),
('KINTEX','pg-cms-microsite','마이크로사이트','SCREEN','/cms/microsite','도메인'),
('KINTEX','pg-cms-i18n','다국어 관리','SCREEN','/cms/i18n','도메인'),
('KINTEX','pg-settlement','정산/청구','SCREEN','/settlement','도메인'),
('KINTEX','pg-work-approval','전자결재','SCREEN','/work/approval','공통업무'),
('KINTEX','pg-work-worklog','업무일지','SCREEN','/work/worklog','공통업무'),
('KINTEX','pg-work-schedule','일정','SCREEN','/work/schedule','공통업무'),
('KINTEX','pg-work-message','쪽지','SCREEN','/work/message','공통업무'),
('KINTEX','pg-work-notice','공지','SCREEN','/work/notice','공통업무'),
('KINTEX','pg-work-opinion','의견','SCREEN','/work/opinion','공통업무'),
('KINTEX','pg-work-meeting','회의','SCREEN','/work/meeting','공통업무'),
('KINTEX','pg-work-report','보고서','SCREEN','/work/report','공통업무'),
('KINTEX','pg-work-search','통합검색','SCREEN','/work/search','공통업무'),
('KINTEX','pg-notifications','알림','SCREEN','/notifications','공통업무'),
('KINTEX','pg-me','내 정보','SCREEN','/me','공통업무'),
('KINTEX','pg-admin','관리 대시보드','SCREEN','/admin','시스템관리'),
('KINTEX','pg-admin-users','사용자 관리','SCREEN','/admin/users','시스템관리'),
('KINTEX','pg-admin-roles','역할/권한','SCREEN','/admin/roles','시스템관리'),
('KINTEX','pg-admin-codes','공통코드','SCREEN','/admin/codes','시스템관리'),
('KINTEX','pg-admin-menus','메뉴 관리','SCREEN','/admin/menus','시스템관리'),
('KINTEX','pg-admin-depts','조직 관리','SCREEN','/admin/depts','시스템관리'),
('KINTEX','pg-admin-companies','회사 관리','SCREEN','/admin/companies','시스템관리'),
('KINTEX','pg-admin-programs','프로그램 관리','SCREEN','/admin/programs','시스템관리'),
('KINTEX','pg-admin-role-menus','역할-메뉴','SCREEN','/admin/role-menus','시스템관리'),
('KINTEX','pg-admin-auth-policy','인증 정책','SCREEN','/admin/auth-policy','시스템관리'),
('KINTEX','pg-admin-login-history','로그인 이력','SCREEN','/admin/login-history','시스템관리'),
('KINTEX','pg-admin-error-log','오류 로그','SCREEN','/admin/error-log','시스템관리'),
('KINTEX','pg-admin-system-health','시스템 상태','SCREEN','/admin/system-health','시스템관리'),
('KINTEX','pg-admin-settings','시스템 설정','SCREEN','/admin/settings','시스템관리'),
('KINTEX','pg-admin-audit','감사로그','SCREEN','/admin/audit','시스템관리'),
('KINTEX','pg-admin-rulesets','규정/요율 룰셋','SCREEN','/admin/masterdata/rulesets','시스템관리'),
('KINTEX','pg-admin-tenants','테넌트 관리','SCREEN','/admin/tenants','시스템관리'),
('KINTEX','pg-admin-login-slides','로그인 슬라이드','SCREEN','/admin/login-slides','시스템관리'),
('KINTEX','pg-admin-mail-config','메일 설정','SCREEN','/admin/mail-config','시스템관리'),
('KINTEX','pg-admin-notify-config','알림 설정','SCREEN','/admin/notify-config','시스템관리'),
('KINTEX','pg-app-qr','앱 QR 배포','SCREEN','/app-qr','시스템관리'),
('KINTEX','pg-public','공개 홈','SCREEN','/public','공개사이트'),
('KINTEX','pg-public-event','공개 행사','SCREEN','/public/events/:eventId','공개사이트'),
('KINTEX','pg-public-floorplan','공개 평면도','SCREEN','/public/events/:eventId/floorplan','공개사이트'),
('KINTEX','pg-public-register','관람객 사전등록','SCREEN','/public/events/:eventId/register','공개사이트'),
('KINTEX','pg-public-exhibitor','공개 참가업체','SCREEN','/public/exhibitors/:exhibitorId','공개사이트'),
('KINTEX','pg-public-inquiry','참가문의','SCREEN','/public/exhibit-inquiry','공개사이트'),
('KINTEX','pg-ticket-purchase','입장권 구매','SCREEN','/tickets/:eventId/purchase','공개사이트'),
('KINTEX','pg-ticket-lookup','입장권 조회','SCREEN','/tickets/lookup','공개사이트'),
('KINTEX','pg-m-checklist','현장 체크리스트(모바일)','SCREEN','/m/booths/:boothId/checklist','모바일'),
('KINTEX','pg-m-inspection','현장 점검(모바일)','SCREEN','/m/booths/:boothId/inspection','모바일')
ON CONFLICT (tenant_id, id) DO NOTHING;
-- 1-3) 부서 트리(dept) — 3레벨 --------------------------------------------------
INSERT INTO dept (tenant_id, id, dept_name, parent_id, sort_order) VALUES
('KINTEX','d-exhib', '전시사업본부', NULL, 1),
('KINTEX','d-hall', '홀운영팀', 'd-exhib', 1),
('KINTEX','d-hall-c1', '제1전시장파트', 'd-hall', 1),
('KINTEX','d-hall-c2', '제2전시장파트', 'd-hall', 2),
('KINTEX','d-sales', '전시영업팀', 'd-exhib', 2),
('KINTEX','d-sales-dom','국내영업파트', 'd-sales', 1),
('KINTEX','d-sales-glo','해외영업파트', 'd-sales', 2),
('KINTEX','d-mkt', '마케팅팀', 'd-exhib', 3),
('KINTEX','d-mgmt', '경영지원본부', NULL, 2),
('KINTEX','d-fin', '재무회계팀', 'd-mgmt', 1),
('KINTEX','d-fin-set', '정산파트', 'd-fin', 1),
('KINTEX','d-hr', '인사총무팀', 'd-mgmt', 2),
('KINTEX','d-it', '정보시스템본부', NULL, 3),
('KINTEX','d-it-ops', '시스템운영팀', 'd-it', 1),
('KINTEX','d-it-dev', '서비스개발팀', 'd-it', 2)
ON CONFLICT (tenant_id, id) DO NOTHING;
-- 1-4) 회사(company) — 공사업체 2건 순증(V6 co-0001~4 포함 총 6) + 참가업체 10건 -----
-- company 는 tenant_id 컬럼 없음(V2 스키마). UNIQUE(registration_no) 멱등 키.
INSERT INTO company (id, registration_no, name, category, region, registered, phone) VALUES
-- 공사(장치)업체 순증 2 (registered=true)
('co-0005', '555-55-55555', '(주)미디어라이트', '영상/음향', '서울', true, '02-0000-5501'),
('co-0006', '666-66-66666', '넷워크시스템즈', '네트워크', '경기', true, '031-000-6601'),
-- 참가업체 10 (전시 참가사 — 등록업체 DB 등재)
('co-ex-01', '301-81-10001', '(주)스마트오토메이션', '제조/자동화', '경기', true, '031-000-1001'),
('co-ex-02', '302-81-10002', '델타로보틱스', '로봇', '서울', true, '02-0000-1002'),
('co-ex-03', '303-81-10003', '한국센서테크', '전기/전자', '인천', true, '032-000-1003'),
('co-ex-04', '304-81-10004', '비전인더스트리', '제조/자동화', '경기', true, '031-000-1004'),
('co-ex-05', '305-81-10005', '(주)테크솔루션', 'ICT/소프트웨어','서울',true, '02-0000-1005'),
('co-ex-06', '306-81-10006', '넥스트이노베이션', 'ICT/소프트웨어','경기',true, '031-000-1006'),
('co-ex-07', '307-81-10007', '글로벌테크', '물류/유통', '부산', true, '051-000-1007'),
('co-ex-08', '308-81-10008', '미래제조연구소', '제조/자동화', '대전', true, '042-000-1008'),
('co-ex-09', '309-81-10009', '두산로보틱스데모', '로봇', '경기', true, '031-000-1009'),
('co-ex-10', '310-81-10010', '바이오헬스랩', '바이오/헬스', '서울', true, '02-0000-1010')
ON CONFLICT (registration_no) DO NOTHING;
-- 공사업체 평판(company_reputation) — 순증 2건(V16 co-0001~4 포함 총 6) ------------
INSERT INTO company_reputation (company_id, rating, jobs_done, claim_rate) VALUES
('co-0005', 4.5, 9, 1.0),
('co-0006', 4.3, 7, 1.8)
ON CONFLICT (company_id) DO NOTHING;
-- 1-5) 요율 부족분 순증(master_data RATE — ON CONFLICT DO NOTHING) -----------------
INSERT INTO master_data (category, code, name, value_json, ruleset_version) VALUES
('RATE', 'independentBoothPerM2', '독립부스 임대 요율(원/㎡)', '2420'::jsonb, 'rates-v1.0'),
('RATE', 'cornerPremiumPercent', '코너 가산율(%)', '10'::jsonb, 'rates-v1.0'),
('RATE', 'lateApplicationPercent','마감 후 신청 가산율(%)', '20'::jsonb, 'rates-v1.0'),
('UTILITY_FEE', 'network.wifiAp', '전용 WiFi AP(원)', '250000'::jsonb,'rates-v1.0'),
('UTILITY_FEE', 'power.night24h', '24시간 전원 상시공급(원/kW)','82500'::jsonb,'rates-v1.0')
ON CONFLICT (category, code) DO NOTHING;
-- ============================================================================
-- 2. 데모 계정 4 (app_user) + 행사 멤버십(event_member)
-- 비밀번호 규칙: Kintex!Demo<Role>1 · BCrypt($2a$10) · otp_enabled=false(데모 편의)
-- ============================================================================
INSERT INTO app_user (tenant_id, id, email, display_name, password_hash, hall_manager,
role_code, status, verify_method, otp_enabled, dept_id, company_name) VALUES
('KINTEX','demo-organizer','demo-organizer@kintex.test','데모 주최자(홍길동)',
'$2a$10$15dUJ7rlPO7kwgC/fgCUFuOK7Q0yS5lC4LTedszf1vn/R7CsShZ2y', false,
'USER','ACTIVE','EMAIL', false, 'd-hall', 'KINTEX 전시사업본부'),
('KINTEX','demo-exhibitor','demo-exhibitor@kintex.test','데모 참가업체(김참가)',
'$2a$10$aaUiIhPWfAMJ/AIfpZYBSurTRwnCYIY1R9mmmgpZyGVTMxXIyxlsC', false,
'USER','ACTIVE','EMAIL', false, NULL, '(주)스마트오토메이션'),
('KINTEX','demo-contractor','demo-contractor@kintex.test','데모 공사업체(이시공)',
'$2a$10$hFPyMTvnOYQwfHcktl9Kuu0W2/Mrl//IiSjS83wOmBd22enBDRj5G', false,
'USER','ACTIVE','EMAIL', false, NULL, '(주)한빛부스디자인'),
('KINTEX','demo-visitor','demo-visitor@kintex.test','데모 관람객(박관람)',
'$2a$10$UhVNjelRRZt3SpDCJfT.V.2pfHH9Lrme9KRteadUUJ1YhFwMvyH4K', false,
'USER','ACTIVE','EMAIL', false, NULL, NULL)
ON CONFLICT (email) DO NOTHING;
-- 행사 단위 RBAC — e-2026-smf. contractor 는 등록업체(co-0001) 연결 → 옥션 응찰 게이트 통과.
INSERT INTO event_member (id, event_id, user_id, role_code, booth_id, company_id) VALUES
('em-demo-org', 'e-2026-smf', 'demo-organizer', 'ORGANIZER', NULL, NULL),
('em-demo-exh', 'e-2026-smf', 'demo-exhibitor', 'EXHIBITOR', 'booth-smf-a102', NULL),
('em-demo-con', 'e-2026-smf', 'demo-contractor', 'CONTRACTOR', NULL, 'co-0001')
ON CONFLICT (event_id, user_id, role_code) DO NOTHING;
-- ============================================================================
-- 3. 부스 폐루프 — 홀 배정 + 데모 배치(layout) + 부스 12(POLYGON) + 판매 오버레이
-- SRID 0 = 홀 로컬 평면(미터). 부스=6m×3m(18㎡) 4×3 그리드.
-- ============================================================================
-- H1 홀 배정(V28 BOQ 레이아웃 정합) + 데모 부스용 배정은 기존 H7(primary) 사용.
INSERT INTO hall_assignment (id, event_id, hall_id, is_primary) VALUES
('ha-2026-smf-h1', 'e-2026-smf', 'H1', false)
ON CONFLICT (event_id, hall_id) DO NOTHING;
INSERT INTO layout (id, event_id, hall_id, version, name, status) VALUES
('ly-smf-demo', 'e-2026-smf', 'H7', 1, 'SMF 데모 배치(판매)', '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-b101','ly-smf-demo','B-101','independent',ST_GeomFromText('POLYGON((0 0,6 0,6 3,0 3,0 0))',0), 6,3,3.5,1.0,'(주)스마트오토메이션',true),
('booth-smf-b102','ly-smf-demo','B-102','assembled', ST_GeomFromText('POLYGON((7 0,13 0,13 3,7 3,7 0))',0), 6,3,3.0,1.0,'델타로보틱스',false),
('booth-smf-b103','ly-smf-demo','B-103','assembled', ST_GeomFromText('POLYGON((14 0,20 0,20 3,14 3,14 0))',0), 6,3,3.0,1.0,NULL,false),
('booth-smf-b104','ly-smf-demo','B-104','corner', ST_GeomFromText('POLYGON((21 0,27 0,27 3,21 3,21 0))',0), 6,3,3.5,1.0,'한국센서테크',false),
('booth-smf-b105','ly-smf-demo','B-105','assembled', ST_GeomFromText('POLYGON((0 4,6 4,6 7,0 7,0 4))',0), 6,3,3.0,1.0,NULL,false),
('booth-smf-b106','ly-smf-demo','B-106','island', ST_GeomFromText('POLYGON((7 4,13 4,13 7,7 7,7 4))',0), 6,3,4.0,1.0,'비전인더스트리',true),
('booth-smf-b107','ly-smf-demo','B-107','assembled', ST_GeomFromText('POLYGON((14 4,20 4,20 7,14 7,14 4))',0), 6,3,3.0,1.0,'(주)테크솔루션',false),
('booth-smf-b108','ly-smf-demo','B-108','assembled', ST_GeomFromText('POLYGON((21 4,27 4,27 7,21 7,21 4))',0), 6,3,3.0,1.0,NULL,false),
('booth-smf-b109','ly-smf-demo','B-109','independent',ST_GeomFromText('POLYGON((0 8,6 8,6 11,0 11,0 8))',0), 6,3,3.5,1.0,'넥스트이노베이션',false),
('booth-smf-b110','ly-smf-demo','B-110','assembled', ST_GeomFromText('POLYGON((7 8,13 8,13 11,7 11,7 8))',0), 6,3,3.0,1.0,NULL,false),
('booth-smf-b111','ly-smf-demo','B-111','corner', ST_GeomFromText('POLYGON((14 8,20 8,20 11,14 11,14 8))',0),6,3,3.5,1.0,'미래제조연구소',false),
('booth-smf-b112','ly-smf-demo','B-112','assembled', ST_GeomFromText('POLYGON((21 8,27 8,27 11,21 11,21 8))',0),6,3,3.0,1.0,'바이오헬스랩',false)
ON CONFLICT (id) DO NOTHING;
-- 판매 오버레이(booth_sale) — 상태 분포: sold 5 · available 4 · held 1 · blocked 2 -------
INSERT INTO booth_sale (tenant_id, id, event_id, hall_id, booth_id, booth_no, status, unit_price,
area_m2, held_by, held_until, sold_to_company, sold_by, sold_at,
blocked_reason, created_by) VALUES
('KINTEX','bs-smf-101','e-2026-smf','H7','booth-smf-b101','B-101','sold', 5445000,18,NULL,NULL,'(주)스마트오토메이션','demo-organizer',now()-interval '20 day',NULL,'system-seed'),
('KINTEX','bs-smf-102','e-2026-smf','H7','booth-smf-b102','B-102','sold', 4356000,18,NULL,NULL,'델타로보틱스','demo-organizer',now()-interval '15 day',NULL,'system-seed'),
('KINTEX','bs-smf-103','e-2026-smf','H7','booth-smf-b103','B-103','available', NULL, 18,NULL,NULL,NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-smf-104','e-2026-smf','H7','booth-smf-b104','B-104','sold', 4791600,18,NULL,NULL,'한국센서테크','demo-organizer',now()-interval '12 day',NULL,'system-seed'),
('KINTEX','bs-smf-105','e-2026-smf','H7','booth-smf-b105','B-105','available', NULL, 18,NULL,NULL,NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-smf-106','e-2026-smf','H7','booth-smf-b106','B-106','sold', 6534000,18,NULL,NULL,'비전인더스트리','demo-organizer',now()-interval '8 day',NULL,'system-seed'),
('KINTEX','bs-smf-107','e-2026-smf','H7','booth-smf-b107','B-107','sold', 4356000,18,NULL,NULL,'(주)테크솔루션','demo-organizer',now()-interval '5 day',NULL,'system-seed'),
('KINTEX','bs-smf-108','e-2026-smf','H7','booth-smf-b108','B-108','held', NULL, 18,'demo-exhibitor',now()+interval '30 minute',NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-smf-109','e-2026-smf','H7','booth-smf-b109','B-109','available', NULL, 18,NULL,NULL,NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-smf-110','e-2026-smf','H7','booth-smf-b110','B-110','blocked', NULL, 18,NULL,NULL,NULL,NULL,NULL,'소방 통로 확보 구역','system-seed'),
('KINTEX','bs-smf-111','e-2026-smf','H7','booth-smf-b111','B-111','available', NULL, 18,NULL,NULL,NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-smf-112','e-2026-smf','H7','booth-smf-b112','B-112','blocked', NULL, 18,NULL,NULL,NULL,NULL,NULL,'전기 분전반 점유','system-seed')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 4. 서류 폐루프 — 마감 임박(D-3) 필수 서류 순증 (홈 '서류 D-3' 할 일 활성화)
-- V14 기존 서류 due=2026-08-04(고정)는 배포 시점 무관하게 임박하지 않으므로
-- 상대일(CURRENT_DATE+3) 신규 doc_type 1건 추가.
-- ============================================================================
INSERT INTO required_document (id, event_id, doc_type, name, status, due_date, sort_order) VALUES
('doc-smf-reinspect','e-2026-smf','safety_reinspection','전기안전 재점검 확인서','draft', CURRENT_DATE + 3, 8)
ON CONFLICT (event_id, doc_type) DO NOTHING;
-- ============================================================================
-- 5. 옥션 폐루프 — 응찰 보강(3~5씩) + 낙찰 선정 대기 옥션 신설(AUC-2026-0511)
-- 기존 V16: AUC-0102(3응찰·진행), AUC-0405(2응찰·진행), AUC-0210(2응찰·낙찰완료).
-- ============================================================================
-- 5-1) 기존 옥션 초대·응찰 보강 -----------------------------------------------
INSERT INTO auction_invite (id, auction_id, company_id) VALUES
('ai-0405-3', 'AUC-2026-0405', 'co-0001'),
('ai-0405-4', 'AUC-2026-0405', 'co-0005'),
('ai-0210-2', 'AUC-2026-0210', 'co-0002')
ON CONFLICT (auction_id, company_id) DO NOTHING;
INSERT INTO bid (id, auction_id, company_id, round, subtotal, vat, total, lead_days) VALUES
('bid-0405-3', 'AUC-2026-0405', 'co-0001', 1, 4400000, 440000, 4400000, 7),
('bid-0405-4', 'AUC-2026-0405', 'co-0005', 1, 4780000, 478000, 4780000, 10),
('bid-0210-3', 'AUC-2026-0210', 'co-0002', 1, 12500000, 1250000, 12500000, 16)
ON CONFLICT (auction_id, company_id) DO NOTHING;
-- 5-2) 낙찰 선정 대기 옥션(마감 경과 + 낙찰 미확정) — 홈 '옥션 낙찰 선정' 할 일 활성화 -----
INSERT INTO auction (id, event_id, title, category, auction_type, award_criteria,
weight_price, weight_reputation, weight_delivery, round, deadline,
material_package_id, materials, accent, created_by) VALUES
('AUC-2026-0511', 'e-2026-smf', 'D-1 옥외 사이니지 설치', '영상/음향', 'reverse', 'comprehensive',
55, 25, 20, 1, now() - interval '1 day', NULL, 'layout,boq', 'primary', 'demo-organizer')
ON CONFLICT (id) DO NOTHING;
INSERT INTO auction_invite (id, auction_id, company_id) VALUES
('ai-0511-1', 'AUC-2026-0511', 'co-0001'),
('ai-0511-2', 'AUC-2026-0511', 'co-0002'),
('ai-0511-3', 'AUC-2026-0511', 'co-0005'),
('ai-0511-4', 'AUC-2026-0511', 'co-0006')
ON CONFLICT (auction_id, company_id) DO NOTHING;
INSERT INTO bid (id, auction_id, company_id, round, subtotal, vat, total, lead_days) VALUES
('bid-0511-1', 'AUC-2026-0511', 'co-0001', 1, 6800000, 680000, 6800000, 9),
('bid-0511-2', 'AUC-2026-0511', 'co-0002', 1, 7100000, 710000, 7100000, 8),
('bid-0511-3', 'AUC-2026-0511', 'co-0005', 1, 6500000, 650000, 6500000, 11),
('bid-0511-4', 'AUC-2026-0511', 'co-0006', 1, 6950000, 695000, 6950000, 10)
ON CONFLICT (auction_id, company_id) DO NOTHING;
-- ============================================================================
-- 6. 관람객·리드 폐루프
-- ============================================================================
-- 6-1) 관람객 사전등록 순증 — 오늘 체크인 분포(홈 KPI todayCheckins 활성화) ----------
-- checkin_at 은 CURRENT_DATE 기준 상대(now()) — 재배포 시점의 '오늘'로 집계.
INSERT INTO visitor_registration
(id, event_id, name, phone, email, company, visitor_type, agree_privacy, agree_marketing,
badge_code, badge_issued, checkin_state, qr_token, checkin_at, registered_at) VALUES
('vr-smf-101','e-2026-smf','조민준','010-0000-1101','visitor101@kintex.test','(주)스마트오토메이션','buyer', true,true, 'KTX-V-0101',true,'done', 'QR-DEMO0000000000000101', now()-interval '3 hour', now()-interval '2 day'),
('vr-smf-102','e-2026-smf','윤서아','010-0000-1102','visitor102@kintex.test','델타로보틱스', 'visitor',true,true, 'KTX-V-0102',true,'done', 'QR-DEMO0000000000000102', now()-interval '2 hour', now()-interval '2 day'),
('vr-smf-103','e-2026-smf','임도현','010-0000-1103','visitor103@kintex.test','한국센서테크', 'vip', true,false,'KTX-V-0103',true,'done', 'QR-DEMO0000000000000103', now()-interval '1 hour', now()-interval '1 day'),
('vr-smf-104','e-2026-smf','장하윤','010-0000-1104','visitor104@kintex.test',NULL, 'visitor',true,true, 'KTX-V-0104',true,'done', 'QR-DEMO0000000000000104', now()-interval '30 minute', now()-interval '1 day'),
('vr-smf-105','e-2026-smf','서지호','010-0000-1105','visitor105@kintex.test','넥스트이노베이션', 'buyer', true,true, 'KTX-V-0105',true,'waiting', 'QR-DEMO0000000000000105', NULL, now()-interval '1 day'),
('vr-smf-106','e-2026-smf','오예린','010-0000-1106','visitor106@kintex.test','글로벌테크', 'visitor',true,false,'KTX-V-0106',false,'waiting','QR-DEMO0000000000000106', NULL, now()-interval '6 hour'),
('vr-smf-demo','e-2026-smf','데모 관람객(박관람)','010-0000-0000','demo-visitor@kintex.test',NULL,'visitor',true,true,'KTX-V-DEMO',true,'done','QR-DEMO000000000000DEMO', now()-interval '90 minute', now()-interval '3 day')
ON CONFLICT (id) DO NOTHING;
-- 6-2) 리드 순증 — 총 15건(기존 5 + 신규 10), 핫(score>=80) 총 4건(기존 87·82 + 신규 88·91) ---
INSERT INTO lead
(id, event_id, booth_id, exhibitor_id, name, phone, email, role, company, product,
interest, score, ai_reasons, activity, followup_draft, ai_generated, collected_at) VALUES
('ld-smf-006','e-2026-smf','booth-smf-b101','demo-exhibitor','문재훈','010-0000-2006','moon@techsol.test','생산기술 팀장','(주)테크솔루션','협동로봇 HR-A1',5,91,
'["부스 체류 18분(평균 대비 300%)","사양서 다운로드 3회","구매 결정권자 직급"]'::jsonb,
'[{"text":"HR-A1 시연 참관","at":"오늘 13:40"},{"text":"견적 요청 폼 제출","at":"오늘 13:55"}]'::jsonb,
'안녕하세요, 문 팀장님. 협동로봇 HR-A1 커스텀 제안서를 준비했습니다. 다음 주 미팅 가능하실까요?', true, now()-interval '5 hour'),
('ld-smf-007','e-2026-smf','booth-smf-b106','demo-exhibitor','배수진','010-0000-2007','bae@visionind.test','구매 담당','비전인더스트리','AI 비전 검사 모듈',5,88,
'["AI 비전 데모 2회 재방문","경쟁사 대비 견적 문의","도입 시기 명확(Q4)"]'::jsonb,
'[{"text":"AI 비전 모듈 상담","at":"오늘 11:20"}]'::jsonb,
'안녕하세요, 배 님. 요청하신 AI 비전 검사 모듈 견적서와 파일럿 프로그램 안내드립니다.', true, now()-interval '7 hour'),
('ld-smf-008','e-2026-smf','booth-smf-b101','demo-exhibitor','신유리','010-0000-2008','shin@deltarobo.test','기술 기획','델타로보틱스','스마트 물류',3,63,
'["물류 자동화 세션 참석","브로슈어 다운로드"]'::jsonb,
'[{"text":"데모 영상 시청","at":"오늘 10:10"}]'::jsonb,
'안녕하세요, 신 님. 스마트 물류 도입 사례집을 보내드립니다.', true, now()-interval '9 hour'),
('ld-smf-009','e-2026-smf','booth-smf-b107',NULL,'홍길동','010-0000-2009','hong@sctsys.test','인프라 엔지니어','SCT시스템','클라우드 모니터링',2,41,
'["짧은 부스 체류","자료 열람 없음"]'::jsonb,
'[{"text":"부스 QR 스캔","at":"오늘 09:50"}]'::jsonb,
'안녕하세요, 홍 님. 클라우드 모니터링 소개서를 보내드립니다.', true, now()-interval '10 hour'),
('ld-smf-010','e-2026-smf','booth-smf-b109',NULL,'김철수','010-0000-2010','kim@globaltech.test','설비 담당','글로벌테크','자율주행 AMR',2,38,
'["AMR 부스 단순 방문"]'::jsonb,
'[{"text":"부스 QR 스캔","at":"오늘 09:30"}]'::jsonb,
'안녕하세요, 김 님. 자율주행 AMR 소개 자료를 보내드립니다.', true, now()-interval '11 hour'),
('ld-smf-011','e-2026-smf','booth-smf-b111',NULL,'이영희','010-0000-2011','lee@fmlab.test','연구원','미래제조연구소','스마트 팩토리 컨설팅',4,72,
'["연구 목적 방문","기술 세션 2회 참석"]'::jsonb,
'[{"text":"기술 상담","at":"오늘 14:30"}]'::jsonb,
'안녕하세요, 이 연구원님. 스마트 팩토리 컨설팅 자료를 보내드립니다.', true, now()-interval '3 hour'),
('ld-smf-012','e-2026-smf','booth-smf-b104',NULL,'박영수','010-0000-2012','park@sensortech.test','품질 담당','한국센서테크','센서 모듈',3,58,
'["센서 데모 관람","카탈로그 수령"]'::jsonb,
'[{"text":"카탈로그 QR 스캔","at":"오늘 12:05"}]'::jsonb,
'안녕하세요, 박 님. 센서 모듈 카탈로그를 보내드립니다.', true, now()-interval '6 hour'),
('ld-smf-013','e-2026-smf','booth-smf-b102',NULL,'정미경','010-0000-2013','jung@nextinno.test','마케팅','넥스트이노베이션','AI 솔루션',3,55,
'["부스 방문","명함 교환"]'::jsonb,
'[{"text":"명함 교환","at":"오늘 15:10"}]'::jsonb,
'안녕하세요, 정 님. AI 솔루션 소개서를 보내드립니다.', true, now()-interval '2 hour'),
('ld-smf-014','e-2026-smf','booth-smf-b106',NULL,'최동욱','010-0000-2014','choi@biohealth.test','R&D','바이오헬스랩','자동화 장비',4,74,
'["자동화 장비 상담","도입 검토 중"]'::jsonb,
'[{"text":"장비 상담","at":"오늘 13:00"}]'::jsonb,
'안녕하세요, 최 님. 자동화 장비 제안서를 보내드립니다.', true, now()-interval '4 hour'),
('ld-smf-015','e-2026-smf','booth-smf-b109',NULL,'강민석','010-0000-2015','kang@doosandemo.test','생산관리','두산로보틱스데모','협동로봇',3,49,
'["로봇 부스 방문"]'::jsonb,
'[{"text":"부스 QR 스캔","at":"오늘 11:45"}]'::jsonb,
'안녕하세요, 강 님. 협동로봇 소개 자료를 보내드립니다.', true, now()-interval '8 hour')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 7. 마케팅 — EDM 캠페인 순증 2 (기존 V18 4건 + 2)
-- ============================================================================
INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate) VALUES
('cp-smf-005','e-2026-smf','참가업체 부스 배정 확정 안내','scheduled', 512, '발송 예정: D-2 10:00', '-', '-'),
('cp-smf-006','e-2026-smf','현장 방문객 만족도 조사','draft', 0, '최종 수정: 방금 전', '-', '-')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 8. 정산 폐루프 — 청구서 순증 4(총 8, 옥션 수수료 1 포함) + 수납/세금계산서/환불
-- 기존 V24 4건(paid·invoiced·overdue·pending). 순증 4 = paid·partial·cancelled·auction_fee.
-- ============================================================================
INSERT INTO invoice (id, event_id, exhibitor_id, exhibitor_name, invoice_no, category, source_ref,
subtotal, vat, total, paid_amount, status, due_date, issued_at, paid_at,
cancelled_at, cancel_reason, line_items, note, created_by) VALUES
-- 완납
('inv-smf-0005','e-2026-smf',NULL,'(주)테크솔루션','SMF-2026-0005','rental',NULL,
7920000, 792000, 8712000, 8712000, 'paid', DATE '2026-07-20', now()-interval '22 day', now()-interval '6 day',
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (72㎡ · B-107)","quantity":72,"unitPrice":110000,"amount":7920000}]'::jsonb,
'완납 처리.', 'system-seed'),
-- 부분수납
('inv-smf-0006','e-2026-smf',NULL,'넥스트이노베이션','SMF-2026-0006','rental',NULL,
10800000, 1080000, 11880000, 4752000, 'invoiced', DATE '2026-07-28', now()-interval '10 day', NULL,
NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (108㎡ · B-109)","quantity":108,"unitPrice":100000,"amount":10800000}]'::jsonb,
'계약금 40% 수납.', 'system-seed'),
-- 취소(환불 대상) — 계약금 납입 후 취소
('inv-smf-0007','e-2026-smf',NULL,'미래제조연구소','SMF-2026-0007','rental',NULL,
6000000, 600000, 6600000, 2640000, 'cancelled', DATE '2026-07-30', now()-interval '14 day', NULL,
now()-interval '3 day', '참가 철회(내부 사정) — 환불 규정 적용',
'[{"category":"RENTAL","description":"부스 임대료 (54㎡ · B-111)","quantity":54,"unitPrice":111111,"amount":6000000}]'::jsonb,
'취소 확정 — 환불 산정 대상.', 'system-seed'),
-- 옥션 낙찰 수수료(F084) — AUC-2026-0210 낙찰(aw-0210, co-0003) 5% 수수료
('inv-smf-0008','e-2026-smf',NULL,'세계전기시설','SMF-2026-0008','auction_fee','aw-0210',
600000, 60000, 660000, 0, 'pending', DATE '2026-08-05', NULL, NULL,
NULL, NULL,
'[{"category":"AUCTION_FEE","description":"옥션 낙찰 수수료 5% (AUC-2026-0210 · 낙찰 12,000,000원)","quantity":1,"unitPrice":600000,"amount":600000}]'::jsonb,
'옥션 낙찰 수수료 청구(발행 대기).', 'system-seed')
ON CONFLICT (id) DO NOTHING;
-- 수납 기록(inv-0005 완납 · inv-0006 부분 · inv-0007 취소 전 계약금) --------------------
INSERT INTO invoice_payment (id, invoice_id, amount, method, memo, recorded_by, recorded_at) VALUES
('pay-smf-0005-1','inv-smf-0005', 8712000, 'transfer', '일시 완납', 'system-seed', now()-interval '6 day'),
('pay-smf-0006-1','inv-smf-0006', 4752000, 'transfer', '계약금 40%', 'system-seed', now()-interval '7 day'),
('pay-smf-0007-1','inv-smf-0007', 2640000, 'transfer', '계약금 40%(취소 전 납입)', 'system-seed', now()-interval '12 day')
ON CONFLICT (id) DO NOTHING;
-- 세금계산서(F083) 1건 — 완납 청구 inv-smf-0001 기반 -----------------------------------
INSERT INTO tax_invoice (tenant_id, id, invoice_id, event_id, tax_invoice_no,
supplier_name, supplier_biz_no, supplier_ceo, supplier_addr,
buyer_name, supply_amount, vat, total, item_summary, status, issued_by) VALUES
('KINTEX','tax-smf-0001','inv-smf-0001','e-2026-smf','TAX-2026-0001',
'킨텍스(KINTEX)','128-81-00000','대표이사','경기도 고양시 일산서구 킨텍스로 217',
'(주)스마트오토메이션', 18000000, 1800000, 19800000, '부스 임대료 외 (108㎡ · A-101)', 'issued', 'system-seed')
ON CONFLICT (id) DO NOTHING;
-- 환불(F085) 1건 — 취소 청구 inv-smf-0007 기준. base=실납입(2,640,000), 개막 D-일 구간 환불율 적용 ----
INSERT INTO invoice_refund (tenant_id, id, invoice_id, event_id, base_amount, refund_rate_percent,
refund_amount, forfeit_amount, days_before_event, bracket_label,
reason, ruleset_version, status, created_by) VALUES
('KINTEX','ref-smf-0007','inv-smf-0007','e-2026-smf', 2640000, 50.00,
1320000, 1320000, (DATE '2026-08-11' - (CURRENT_DATE - 3)), '개막 30~15일 전(50%)',
'참가 철회 — 환불 규정(refund-v1.0) 구간 적용', 'refund-v1.0', 'recorded', 'system-seed')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 9. 전자결재 폐루프 — 문서 5 (demo-organizer 가 현재 단계 결재자 → 홈 '결재 대기' 활성화)
-- 상태: SUBMITTED×2 · IN_PROGRESS×1 · APPROVED×1 · DRAFT×1
-- ★ approver 는 demo-organizer(결정론적). env 관리자(ADMIN)는 broad KPI(서류·미수·낙찰)로 별도 활성.
-- ============================================================================
INSERT INTO approval (tenant_id, id, event_id, doc_type, title, content, ref_type, ref_id,
drafter_id, drafter_name, status, cur_step, version, submitted_at, completed_at) VALUES
('KINTEX','ap-smf-01','e-2026-smf','GENERAL','부스 B-101 설계안 승인 요청','A-102 독립부스 최종 설계안(3.5m 높이) 승인 요청.','DOCUMENT','booth-smf-b101',
'demo-exhibitor','데모 참가업체(김참가)','SUBMITTED',1,1, now()-interval '2 day', NULL),
('KINTEX','ap-smf-02','e-2026-smf','GENERAL','유틸리티 신청 승인 요청','전기·네트워크 유틸리티 신청 검토 승인 요청.','DOCUMENT','uo-smf-a102',
'demo-exhibitor','데모 참가업체(김참가)','SUBMITTED',1,1, now()-interval '1 day', NULL),
('KINTEX','ap-smf-03','e-2026-smf','GENERAL','옥션 낙찰 결과 승인(AUC-0210)','C-10 통로 조명 공사 낙찰(세계전기시설) 결과 승인.','GENERAL',NULL,
'demo-contractor','데모 공사업체(이시공)','IN_PROGRESS',2,1, now()-interval '3 day', NULL),
('KINTEX','ap-smf-04','e-2026-smf','GENERAL','재해대처계획서 검수 승인','재해대처계획서 최종본 검수 승인(완료).','DOCUMENT','disaster_plan',
'demo-exhibitor','데모 참가업체(김참가)','APPROVED',1,1, now()-interval '5 day', now()-interval '4 day'),
('KINTEX','ap-smf-05','e-2026-smf','GENERAL','스폰서십 계약 품의(임시저장)','다이아몬드 스폰서 계약 품의 초안.','GENERAL',NULL,
'demo-organizer','데모 주최자(홍길동)','DRAFT',0,1, NULL, NULL)
ON CONFLICT (id) DO NOTHING;
INSERT INTO approval_line (tenant_id, id, approval_id, step_no, approver_id, approver_name, line_status, acted_at, comment) VALUES
-- ap-01: 1단계 CURRENT = demo-organizer
('KINTEX','al-smf-01-1','ap-smf-01',1,'demo-organizer','데모 주최자(홍길동)','CURRENT',NULL,NULL),
-- ap-02: 1단계 CURRENT = demo-organizer
('KINTEX','al-smf-02-1','ap-smf-02',1,'demo-organizer','데모 주최자(홍길동)','CURRENT',NULL,NULL),
-- ap-03: 1단계 APPROVED(demo-exhibitor) → 2단계 CURRENT = demo-organizer
('KINTEX','al-smf-03-1','ap-smf-03',1,'demo-exhibitor','데모 참가업체(김참가)','APPROVED', now()-interval '2 day','1차 확인 완료'),
('KINTEX','al-smf-03-2','ap-smf-03',2,'demo-organizer','데모 주최자(홍길동)','CURRENT',NULL,NULL),
-- ap-04: 1단계 APPROVED(demo-organizer) → 종결
('KINTEX','al-smf-04-1','ap-smf-04',1,'demo-organizer','데모 주최자(홍길동)','APPROVED', now()-interval '4 day','검수 승인'),
-- ap-05: DRAFT — 결재선 1단계 WAIT
('KINTEX','al-smf-05-1','ap-smf-05',1,'demo-organizer','데모 주최자(홍길동)','WAIT',NULL,NULL)
ON CONFLICT (tenant_id, id) DO NOTHING;
INSERT INTO approval_history (tenant_id, id, approval_id, step_no, actor_id, actor_name, action, comment, acted_at) VALUES
('KINTEX','ah-smf-01-1','ap-smf-01',1,'demo-exhibitor','데모 참가업체(김참가)','SUBMIT','설계안 상신', now()-interval '2 day'),
('KINTEX','ah-smf-02-1','ap-smf-02',1,'demo-exhibitor','데모 참가업체(김참가)','SUBMIT','유틸리티 신청 상신', now()-interval '1 day'),
('KINTEX','ah-smf-03-1','ap-smf-03',1,'demo-contractor','데모 공사업체(이시공)','SUBMIT','낙찰 승인 상신', now()-interval '3 day'),
('KINTEX','ah-smf-03-2','ap-smf-03',1,'demo-exhibitor','데모 참가업체(김참가)','APPROVE','1차 확인 완료', now()-interval '2 day'),
('KINTEX','ah-smf-04-1','ap-smf-04',1,'demo-exhibitor','데모 참가업체(김참가)','SUBMIT','계획서 검수 상신', now()-interval '5 day'),
('KINTEX','ah-smf-04-2','ap-smf-04',1,'demo-organizer','데모 주최자(홍길동)','APPROVE','검수 승인', now()-interval '4 day')
ON CONFLICT (tenant_id, id) DO NOTHING;
-- ============================================================================
-- 10. 공지(notice) 6 + 알림(notification) + 업무일지/일정(§5B)
-- ============================================================================
INSERT INTO notice (id, event_id, category, title, content, pinned, author_id, author_name, view_count, published_at) VALUES
('nt-smf-01','e-2026-smf','SYSTEM','[중요] 2026 스마트팩토리 코리아 반입 일정 안내','반입은 개막 D-2 09:00부터 가능합니다. 차량 등록 필수.', true, 'demo-organizer','데모 주최자(홍길동)', 342, now()-interval '5 day'),
('nt-smf-02','e-2026-smf','EVENT','참가업체 필수 서류 제출 마감 안내','재해대처계획서·리깅 구조계산서 마감이 임박했습니다. 기한 내 제출 바랍니다.', true, 'demo-organizer','데모 주최자(홍길동)', 288, now()-interval '3 day'),
('nt-smf-03','e-2026-smf','GENERAL','부스 장치공사 안전관리 지침','장치물 최고 높이 5m, 리깅 허용 6.5~8.5m 준수 바랍니다.', false, 'demo-organizer','데모 주최자(홍길동)', 156, now()-interval '2 day'),
('nt-smf-04','e-2026-smf','GENERAL','전기·네트워크 유틸리티 신청 마감 안내','유틸리티 신청은 개막 D-7까지 접수합니다.', false, 'demo-organizer','데모 주최자(홍길동)', 121, now()-interval '2 day'),
('nt-smf-05','e-2026-smf','EVENT','VIP 바이어 네트워킹 데이 안내','개막 2일차 오후 네트워킹 라운지 운영.', false, 'demo-organizer','데모 주최자(홍길동)', 98, now()-interval '1 day'),
('nt-smf-06',NULL,'SYSTEM','[시스템] 정기 점검 안내','매주 일요일 02:00~04:00 정기 점검이 진행됩니다.', false, 'demo-organizer','데모 주최자(홍길동)', 64, now()-interval '6 hour')
ON CONFLICT (id) DO NOTHING;
INSERT INTO notification (id, recipient_id, event_id, noti_type, title, message, link, read_at) VALUES
('no-smf-01','demo-organizer','e-2026-smf','APPROVAL','결재 대기 문서','부스 B-101 설계안 승인 요청이 도착했습니다.', '/work/approval', NULL),
('no-smf-02','demo-organizer','e-2026-smf','DEADLINE','서류 마감 임박','전기안전 재점검 확인서 마감 D-3.', '/docs', NULL),
('no-smf-03','demo-organizer','e-2026-smf','SYSTEM','옥션 낙찰 선정 대기','D-1 옥외 사이니지 설치 옥션 마감 — 낙찰 선정이 필요합니다.', '/auctions/AUC-2026-0511/award', NULL),
('no-smf-04','demo-contractor','e-2026-smf','DEADLINE','응찰 마감 임박','A-102 독립부스 시공 응찰 마감 D-2.', '/auctions/AUC-2026-0102', NULL),
('no-smf-05','demo-exhibitor','e-2026-smf','MESSAGE','부스 배정 확정','B-101 부스가 배정되었습니다.', '/booth-sales', now()-interval '1 day')
ON CONFLICT (id) DO NOTHING;
INSERT INTO worklog (id, event_id, writer_id, writer_name, dept_id, work_date, title, work_type, progress, status, content, hours) VALUES
('wl-smf-01','e-2026-smf','demo-organizer','데모 주최자(홍길동)','d-hall', CURRENT_DATE, '홀 배치 확정 및 판매 오픈','OPERATION','P100','DONE','H7 데모 배치 12부스 판매 상태 설정 완료.', 4.0),
('wl-smf-02','e-2026-smf','demo-organizer','데모 주최자(홍길동)','d-hall', CURRENT_DATE-1, '서류 검수 및 결재 상신','OPERATION','P75','DOING','필수 서류 검수 진행, 재해대처계획서 반려 후 재요청.', 3.5),
('wl-smf-03','e-2026-smf','demo-organizer','데모 주최자(홍길동)','d-hall', CURRENT_DATE-2, '옥션 3건 응찰 현황 점검','OPERATION','P50','DOING','AUC-0102/0405/0210 응찰 모니터링.', 2.5)
ON CONFLICT (id) DO NOTHING;
INSERT INTO schedule (id, event_id, owner_id, owner_name, dept_id, title, schedule_type, importance, start_at, end_at, all_day, location, content) VALUES
('sc-smf-01','e-2026-smf','demo-organizer','데모 주최자(홍길동)','d-hall','주최자 운영 회의','TEAM','HIGH', now()+interval '1 day', now()+interval '1 day 1 hour', false, '킨텍스 제1전시장 회의실', '개막 준비 점검 회의'),
('sc-smf-02','e-2026-smf','demo-organizer','데모 주최자(홍길동)','d-hall','옥션 낙찰 선정','EVENT','URGENT', now()+interval '2 hour', now()+interval '3 hour', false, '온라인', 'AUC-2026-0511 낙찰 선정'),
('sc-smf-03','e-2026-smf','demo-organizer','데모 주최자(홍길동)','d-hall','반입 리허설','EVENT','NORMAL', now()+interval '5 day', now()+interval '5 day 4 hour', false, '킨텍스 제2전시장 H7', '반입 동선 리허설')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 검증 쿼리(수동 실행용 — 마이그레이션에는 포함되지 않는 주석):
-- -- 데모 계정: SELECT id,email,role_code,otp_enabled FROM app_user WHERE email LIKE '%@kintex.test';
-- -- demo-organizer 결재대기: SELECT count(*) FROM approval_line al JOIN approval a ON a.id=al.approval_id
-- -- WHERE al.approver_id='demo-organizer' AND al.line_status='CURRENT' AND a.status IN('SUBMITTED','IN_PROGRESS'); -- 기대 3
-- -- 서류 D-7 이내 미승인: SELECT count(*) FROM required_document WHERE event_id='e-2026-smf'
-- -- AND status<>'approved' AND due_date IS NOT NULL AND due_date<=CURRENT_DATE+7; -- >=1 (doc-smf-reinspect)
-- -- 낙찰 미확정 마감경과 옥션: SELECT count(*) FROM auction a WHERE a.deadline<now()
-- -- AND NOT EXISTS(SELECT 1 FROM award w WHERE w.auction_id=a.id); -- >=1 (AUC-2026-0511)
-- -- 오늘 체크인: SELECT count(*) FROM visitor_registration WHERE checkin_at::date=CURRENT_DATE; -- 5
-- -- 미수 청구: SELECT count(*) FROM invoice i JOIN event e ON e.id=i.event_id
-- -- WHERE e.tenant_id='KINTEX' AND i.status IN('pending','overdue'); -- >=3
-- -- 부스 판매분포: SELECT status,count(*) FROM booth_sale WHERE event_id='e-2026-smf' GROUP BY status;
-- -- 리드 15/핫4: SELECT count(*), count(*) FILTER(WHERE score>=80) FROM lead WHERE event_id='e-2026-smf';
-- ============================================================================

View File

@ -0,0 +1,87 @@
package com.zioinfo.kintex.mail;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mail.javamail.JavaMailSender;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* MailService/MailBatch 단위 테스트 비활성(MAIL_ENABLED=false) 스킵 수신자 마스킹 배치 분할.
* SMTP 연동 없이 순수 로직만 검증한다.
*/
class MailServiceTest {
@SuppressWarnings("unchecked")
private ObjectProvider<JavaMailSender> providerOf(JavaMailSender sender) {
ObjectProvider<JavaMailSender> p = mock(ObjectProvider.class);
when(p.getIfAvailable()).thenReturn(sender);
return p;
}
@Test
void disabled_skips_actual_send_and_records_skipped() {
MailProperties props = new MailProperties();
props.setEnabled(false); // MAIL_ENABLED=false
JavaMailSender sender = mock(JavaMailSender.class);
MailLogMapper mapper = mock(MailLogMapper.class);
MailService svc = new MailService(props, providerOf(sender), mapper);
svc.send("AUCTION_OPEN", "AUC-1", "kimchulsu@domain.com", "제목", "<p>body</p>");
// 실제 SMTP 전송은 발생하지 않는다.
verify(sender, never()).send(any(jakarta.mail.internet.MimeMessage.class));
// 이력은 skipped 상태 + 마스킹 수신자로 기록.
ArgumentCaptor<String> statusCap = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> recipientCap = ArgumentCaptor.forClass(String.class);
verify(mapper).insert(anyString(), eq("AUCTION_OPEN"), eq("AUC-1"), anyString(),
recipientCap.capture(), statusCap.capture(), any());
assertThat(statusCap.getValue()).isEqualTo("skipped");
assertThat(recipientCap.getValue()).isEqualTo("kim***@domain.com");
}
@Test
void sender_absent_also_skips() {
MailProperties props = new MailProperties();
props.setEnabled(true); // 켜져 있어도 sender 없으면 스킵
MailLogMapper mapper = mock(MailLogMapper.class);
MailService svc = new MailService(props, providerOf(null), mapper);
svc.send("EDM", "cp-1", "a@b.com", "제목", "<p>body</p>");
verify(mapper).insert(anyString(), eq("EDM"), eq("cp-1"), anyString(),
anyString(), eq("skipped"), any());
}
@Test
void maskEmail_masks_local_part() {
assertThat(MailService.maskEmail("kimchulsu@domain.com")).isEqualTo("kim***@domain.com");
assertThat(MailService.maskEmail("ab@x.com")).isEqualTo("a***@x.com");
assertThat(MailService.maskEmail(null)).isEqualTo("***");
assertThat(MailService.maskEmail("noatsign")).isEqualTo("***");
}
@Test
void batch_partitions_by_size() {
List<Integer> items = new java.util.ArrayList<>();
for (int i = 0; i < 120; i++) {
items.add(i);
}
List<List<Integer>> batches = MailBatch.partition(items, 50);
assertThat(batches).hasSize(3);
assertThat(batches.get(0)).hasSize(50);
assertThat(batches.get(1)).hasSize(50);
assertThat(batches.get(2)).hasSize(20);
}
@Test
void batch_empty_and_nonpositive() {
assertThat(MailBatch.partition(List.<Integer>of(), 50)).isEmpty();
assertThat(MailBatch.partition(List.of(1, 2, 3), 0)).hasSize(1);
}
}

View File

@ -2,6 +2,7 @@ package com.zioinfo.kintex.security;
import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.system.loginhistory.LoginHistoryService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@ -32,7 +33,7 @@ class LoginAttemptServiceTest {
SecurityPolicyProperties props = new SecurityPolicyProperties(); SecurityPolicyProperties props = new SecurityPolicyProperties();
props.getLockout().setMaxFailedAttempts(5); props.getLockout().setMaxFailedAttempts(5);
props.getLockout().setLockMinutes(30); props.getLockout().setLockMinutes(30);
service = new LoginAttemptService(mapper, props); service = new LoginAttemptService(mapper, props, mock(LoginHistoryService.class));
} }
private Map<String, Object> row(int failedCount, OffsetDateTime lockedUntil) { private Map<String, Object> row(int failedCount, OffsetDateTime lockedUntil) {

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KINTEX AI 전시관리</title> <title>KINTEX AI 전시·행사시스템</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link <link
rel="stylesheet" rel="stylesheet"

View File

@ -14,6 +14,16 @@ import { ManagerApprovalQueuePage } from './screens/approval/ManagerApprovalQueu
import { ReviewDetailsPage } from './screens/approval/ReviewDetailsPage'; import { ReviewDetailsPage } from './screens/approval/ReviewDetailsPage';
import { HomePage } from './screens/home/HomePage'; import { HomePage } from './screens/home/HomePage';
import { BoothSalesPage } from './screens/boothsales/BoothSalesPage'; import { BoothSalesPage } from './screens/boothsales/BoothSalesPage';
import { AppQrCodePage } from './screens/appqr/AppQrCodePage';
import { DeptAdminPage } from './screens/admin/DeptAdminPage';
import { CompanyAdminPage } from './screens/admin/CompanyAdminPage';
import { ProgramAdminPage } from './screens/admin/ProgramAdminPage';
import { RoleMenuAdminPage } from './screens/admin/RoleMenuAdminPage';
import { AuthPolicyPage } from './screens/admin/AuthPolicyPage';
import { LoginHistoryPage } from './screens/admin/LoginHistoryPage';
import { ErrorLogPage } from './screens/admin/ErrorLogPage';
import { SystemHealthPage } from './screens/admin/SystemHealthPage';
import { MailConfigPage, NotifyConfigPage } from './screens/admin/InfraConfigPage';
import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage'; import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage';
import { AnalyticsDashboardPage } from './screens/analytics/AnalyticsDashboardPage'; import { AnalyticsDashboardPage } from './screens/analytics/AnalyticsDashboardPage';
import { HallOperationsPage } from './screens/ops/HallOperationsPage'; import { HallOperationsPage } from './screens/ops/HallOperationsPage';
@ -83,6 +93,12 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
/** 루트/미매칭 분기 — 인증=홈, 미인증=공개 소개 랜딩(제품 아이덴티티, 2026-07-12 소유자 지적). */
function RootGate() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return <Navigate to={isAuthenticated ? '/home' : '/public'} replace />;
}
export function App() { export function App() {
return ( return (
<Routes> <Routes>
@ -156,6 +172,8 @@ export function App() {
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
{/* F005/F006 부스 판매(셀프 선택) */} {/* F005/F006 부스 판매(셀프 선택) */}
<Route path="/booth-sales" element={<BoothSalesPage />} /> <Route path="/booth-sales" element={<BoothSalesPage />} />
{/* 모바일 앱 설치 QR (WISE AppQrCode 패턴) */}
<Route path="/app-qr" element={<AppQrCodePage />} />
{/* §5B 전자결재 결재함(F028) — 홈 내 할 일 route 정합(/work/approval) */} {/* §5B 전자결재 결재함(F028) — 홈 내 할 일 route 정합(/work/approval) */}
<Route path="/work/approval" element={<ManagerApprovalQueuePage />} /> <Route path="/work/approval" element={<ManagerApprovalQueuePage />} />
{/* SCR-15 전시 일정 관리 (M1·M6) */} {/* SCR-15 전시 일정 관리 (M1·M6) */}
@ -213,6 +231,17 @@ export function App() {
<Route path="/admin/roles" element={<AdminGuard><RoleAdminPage /></AdminGuard>} /> <Route path="/admin/roles" element={<AdminGuard><RoleAdminPage /></AdminGuard>} />
<Route path="/admin/codes" element={<AdminGuard><CommonCodeAdminPage /></AdminGuard>} /> <Route path="/admin/codes" element={<AdminGuard><CommonCodeAdminPage /></AdminGuard>} />
<Route path="/admin/menus" element={<AdminGuard><MenuAdminPage /></AdminGuard>} /> <Route path="/admin/menus" element={<AdminGuard><MenuAdminPage /></AdminGuard>} />
{/* W12 WISE 시스템관리 기본기 8종 (V37) */}
<Route path="/admin/depts" element={<AdminGuard><DeptAdminPage /></AdminGuard>} />
<Route path="/admin/companies" element={<AdminGuard><CompanyAdminPage /></AdminGuard>} />
<Route path="/admin/programs" element={<AdminGuard><ProgramAdminPage /></AdminGuard>} />
<Route path="/admin/role-menus" element={<AdminGuard><RoleMenuAdminPage /></AdminGuard>} />
<Route path="/admin/auth-policy" element={<AdminGuard><AuthPolicyPage /></AdminGuard>} />
<Route path="/admin/login-history" element={<AdminGuard><LoginHistoryPage /></AdminGuard>} />
<Route path="/admin/error-log" element={<AdminGuard><ErrorLogPage /></AdminGuard>} />
<Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} />
<Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} />
<Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} />
</Route> </Route>
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */} {/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
@ -239,7 +268,8 @@ export function App() {
<Route path="/m/booths/:boothId/inspection" element={<ManagerFieldInspectionPage />} /> <Route path="/m/booths/:boothId/inspection" element={<ManagerFieldInspectionPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="/" element={<RootGate />} />
<Route path="*" element={<RootGate />} />
</Routes> </Routes>
); );
} }

View File

@ -1,9 +1,12 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { useMdiStore } from '../../store/mdiStore'; import { useMdiStore } from '../../store/mdiStore';
import { useUiStore, isMobileViewport } from '../../store/uiStore';
import { useIsAdmin } from '../../screens/admin/AdminGuard';
import { MdiTabBar } from './MdiTabBar'; import { MdiTabBar } from './MdiTabBar';
import { ShellFooter } from './ShellFooter'; import { ShellFooter } from './ShellFooter';
import { labelForPath } from './mdiLabels'; import { labelForPath } from './mdiLabels';
@ -15,17 +18,21 @@ import {
IconBell, IconBell,
IconCalendar, IconCalendar,
IconCheckCircle, IconCheckCircle,
IconChevronDown,
IconDashboard, IconDashboard,
IconDocument, IconDocument,
IconExhibitors, IconExhibitors,
IconFloorplan, IconFloorplan,
IconGrid,
IconHome, IconHome,
IconLogOut,
IconMenu,
IconMoon, IconMoon,
IconOperations, IconOperations,
IconSearch,
IconSettings, IconSettings,
IconSettlement, IconSettlement,
IconSun, IconSun,
IconUser,
IconUsers, IconUsers,
type IconProps, type IconProps,
} from '../ui/icons'; } from '../ui/icons';
@ -34,71 +41,181 @@ import './app-shell.css';
/** /**
* . `to` react-router NavLink( ). * . `to` react-router NavLink( ).
* `scoped: true` (··) (:eventId) * `scoped: true` (··) (:eventId)
* to useResolvedEventId , * to useResolvedEventId .
* ( ) (/login ) .
*/ */
const NAV_ITEMS: { interface NavItem {
key: string; key: string;
/** 링크 라벨(또는 section=true 인 경우 소그룹 구분 헤더 텍스트) — i18n 미해소 시 폴백. */
label: string; label: string;
Icon: ComponentType<IconProps>; /** i18n 키(shell.menu.*). 없으면 label 을 그대로 사용. */
labelKey?: string;
/** 서브항목은 아이콘 대신 점(dot)으로 렌더 — Icon 은 메타(선택). */
Icon?: ComponentType<IconProps>;
to?: string; to?: string;
scoped?: boolean; scoped?: boolean;
}[] = [ /** true → 클릭 불가한 소그룹 구분 헤더(시스템관리 20항목 정리용). */
// 메인 홈(SCR-HOME) — 로그인 후 랜딩. 라우트 /home 배선은 통합자(App.tsx) 소관. section?: boolean;
{ key: 'home', label: '홈', Icon: IconHome, to: '/home' }, }
{ key: 'dashboard', label: '대시보드', Icon: IconDashboard, scoped: true }, interface NavGroupDef {
{ key: 'floorplan', label: '플로어플랜 스튜디오', Icon: IconFloorplan, scoped: true }, id: string;
{ key: 'exhibitors', label: '참가업체 관리', Icon: IconExhibitors, scoped: true }, label: string;
{ key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' }, labelKey?: string;
{ key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' }, Icon: ComponentType<IconProps>;
{ key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' }, items: NavItem[];
{ key: 'documents', label: '서류·마일스톤', Icon: IconDocument, to: '/docs' }, /** 관리자 전용 그룹 — useIsAdmin() 시에만 노출. */
// 정산 — 이번 웨이브 신설. 라우트(/settlement)는 통합자가 App.tsx 에 배선(요청서 §통합자 배선). admin?: boolean;
{ key: 'settlement', label: '정산', Icon: IconSettlement, to: '/settlement' }, }
{ key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' },
/** 홈 — 로그인 후 랜딩(단일 최상위 링크, 아코디언 밖). */
const HOME_ITEM: NavItem = { key: 'home', label: '홈', labelKey: 'shell.menu.home', Icon: IconHome, to: '/home' };
/**
* IA ( ) , .
* . WISE menuTree .
* shell.menu.* i18n(4 ) , label (ko).
*/
const GROUPS: NavGroupDef[] = [
{
id: 'ops',
label: '전시 운영',
labelKey: 'shell.menu.groups.ops',
Icon: IconOperations,
items: [
{ key: 'dashboard', label: '대시보드', labelKey: 'shell.menu.items.dashboard', Icon: IconDashboard, scoped: true },
{ key: 'exhibitors', label: '참가업체 관리', labelKey: 'shell.menu.items.exhibitors', Icon: IconExhibitors, scoped: true },
{ key: 'schedule', label: '전시 일정', labelKey: 'shell.menu.items.schedule', Icon: IconCalendar, to: '/schedule' },
{ key: 'halls', label: '홀 배정', labelKey: 'shell.menu.items.halls', Icon: IconGrid, to: '/halls' },
{ key: 'booth-sales', label: '부스 판매', labelKey: 'shell.menu.items.boothSales', Icon: IconSettlement, to: '/booth-sales' },
{ key: 'operations', label: '현장운영', labelKey: 'shell.menu.items.operations', Icon: IconOperations, to: '/ops/operations' },
],
},
{
id: 'design',
label: '설계·시공',
labelKey: 'shell.menu.groups.design',
Icon: IconFloorplan,
items: [
{ key: 'floorplan', label: '플로어플랜 스튜디오', labelKey: 'shell.menu.items.floorplan', Icon: IconFloorplan, scoped: true },
{ key: 'documents', label: '서류·마일스톤', labelKey: 'shell.menu.items.documents', Icon: IconDocument, to: '/docs' },
{ key: 'auctions', label: '공사 옥션', labelKey: 'shell.menu.items.auctions', Icon: IconSettlement, to: '/auctions' },
{ key: 'contractor', label: '수주 부스', labelKey: 'shell.menu.items.contractor', Icon: IconExhibitors, to: '/contractor/dashboard' },
{ key: 'logistics', label: '반입·반출', labelKey: 'shell.menu.items.logistics', Icon: IconOperations, to: '/logistics' },
],
},
{
id: 'visitor',
label: '관람객·마케팅',
labelKey: 'shell.menu.groups.visitor',
Icon: IconUsers,
items: [
{ key: 'visitors', label: '관람객', labelKey: 'shell.menu.items.visitors', Icon: IconUsers, to: '/visitors' },
{ key: 'checkin', label: '체크인 데스크', labelKey: 'shell.menu.items.checkin', Icon: IconCheckCircle, to: '/visitors/checkin' },
{ key: 'leads', label: '리드', labelKey: 'shell.menu.items.leads', Icon: IconExhibitors, to: '/leads' },
{ key: 'campaigns', label: '마케팅', labelKey: 'shell.menu.items.campaigns', Icon: IconBell, to: '/campaigns' },
{ key: 'sponsorship', label: '스폰서십', labelKey: 'shell.menu.items.sponsorship', Icon: IconSettlement, to: '/sponsorship' },
{ key: 'cms', label: '콘텐츠 CMS', labelKey: 'shell.menu.items.cms', Icon: IconDocument, to: '/cms' },
],
},
{
id: 'finance',
label: '정산·분석',
labelKey: 'shell.menu.groups.finance',
Icon: IconAnalytics,
items: [
{ key: 'settlement', label: '정산', labelKey: 'shell.menu.items.settlement', Icon: IconSettlement, to: '/settlement' },
{ key: 'analytics', label: '경영분석', labelKey: 'shell.menu.items.analytics', Icon: IconAnalytics, to: '/analytics' },
],
},
{
id: 'work',
label: '업무 공통',
labelKey: 'shell.menu.groups.work',
Icon: IconDocument,
items: [
{ key: 'worklog', label: '업무일지', labelKey: 'shell.menu.items.worklog', Icon: IconDocument, to: '/work/worklog' },
{ key: 'work-schedule', label: '일정', labelKey: 'shell.menu.items.workSchedule', Icon: IconCalendar, to: '/work/schedule' },
{ key: 'message', label: '쪽지', labelKey: 'shell.menu.items.message', Icon: IconUsers, to: '/work/message' },
{ key: 'notice', label: '공지', labelKey: 'shell.menu.items.notice', Icon: IconBell, to: '/work/notice' },
{ key: 'meeting', label: '회의록', labelKey: 'shell.menu.items.meeting', Icon: IconOperations, to: '/work/meeting' },
{ key: 'report', label: '업무보고', labelKey: 'shell.menu.items.report', Icon: IconAnalytics, to: '/work/report' },
{ key: 'approval', label: '결재', labelKey: 'shell.menu.items.approval', Icon: IconCheckCircle, to: '/work/approval' },
{ key: 'opinion', label: '의견접수', labelKey: 'shell.menu.items.opinion', Icon: IconCheckCircle, to: '/work/opinion' },
{ key: 'search', label: '통합검색', labelKey: 'shell.menu.items.search', Icon: IconGrid, to: '/work/search' },
{ key: 'app-qr', label: '모바일 앱 설치', labelKey: 'shell.menu.items.appqr', Icon: IconGrid, to: '/app-qr' },
],
},
{
id: 'system',
label: '시스템관리',
labelKey: 'shell.menu.groups.system',
Icon: IconSettings,
admin: true,
items: [
{ key: 'admin', label: '관리 대시보드', labelKey: 'shell.menu.items.admin', Icon: IconDashboard, to: '/admin' },
// 사용자·조직
{ key: 'sec-userorg', label: '사용자·조직', labelKey: 'shell.menu.sections.userOrg', section: true },
{ key: 'admin-users', label: '사용자 관리', labelKey: 'shell.menu.items.users', Icon: IconUsers, to: '/admin/users' },
{ key: 'admin-roles', label: '역할/권한', labelKey: 'shell.menu.items.roles', Icon: IconCheckCircle, to: '/admin/roles' },
{ key: 'admin-depts', label: '조직 관리', labelKey: 'shell.menu.items.depts', Icon: IconUsers, to: '/admin/depts' },
{ key: 'admin-companies', label: '회사 관리', labelKey: 'shell.menu.items.companies', Icon: IconExhibitors, to: '/admin/companies' },
// 메뉴·프로그램·코드
{ key: 'sec-menuauth', label: '메뉴·프로그램·코드', labelKey: 'shell.menu.sections.menuAuth', section: true },
{ key: 'admin-menus', label: '메뉴 관리', labelKey: 'shell.menu.items.menus', Icon: IconDocument, to: '/admin/menus' },
{ key: 'admin-programs', label: '프로그램 관리', labelKey: 'shell.menu.items.programs', Icon: IconGrid, to: '/admin/programs' },
{ key: 'admin-role-menus', label: '역할-메뉴 매핑', labelKey: 'shell.menu.items.roleMenus', Icon: IconCheckCircle, to: '/admin/role-menus' },
{ key: 'admin-codes', label: '공통코드', labelKey: 'shell.menu.items.codes', Icon: IconGrid, to: '/admin/codes' },
// 정책·이력·로그
{ key: 'sec-policylog', label: '정책·이력·로그', labelKey: 'shell.menu.sections.policyLog', section: true },
{ key: 'admin-auth-policy', label: '인증 정책', labelKey: 'shell.menu.items.authPolicy', Icon: IconSettings, to: '/admin/auth-policy' },
{ key: 'admin-login-history', label: '로그인 이력', labelKey: 'shell.menu.items.loginHistory', Icon: IconDocument, to: '/admin/login-history' },
{ key: 'admin-audit', label: '감사로그', labelKey: 'shell.menu.items.audit', Icon: IconDocument, to: '/admin/audit' },
{ key: 'admin-error-log', label: '오류 로그', labelKey: 'shell.menu.items.errorLog', Icon: IconDocument, to: '/admin/error-log' },
// 상태·설정
{ key: 'sec-statusconfig', label: '상태·설정', labelKey: 'shell.menu.sections.statusConfig', section: true },
{ key: 'admin-system-health', label: '시스템 상태', labelKey: 'shell.menu.items.systemHealth', Icon: IconAnalytics, to: '/admin/system-health' },
{ key: 'admin-settings', label: '시스템설정', labelKey: 'shell.menu.items.settings', Icon: IconSettings, to: '/admin/settings' },
{ key: 'admin-mail-config', label: '메일 설정', labelKey: 'shell.menu.items.mailConfig', Icon: IconBell, to: '/admin/mail-config' },
{ key: 'admin-notify-config', label: '알림 설정', labelKey: 'shell.menu.items.notifyConfig', Icon: IconBell, to: '/admin/notify-config' },
// 마스터·기타
{ key: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true },
{ key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' },
{ key: 'admin-rulesets', label: '룰셋', labelKey: 'shell.menu.items.rulesets', Icon: IconOperations, to: '/admin/masterdata/rulesets' },
{ key: 'admin-slides', label: '로그인 슬라이드', labelKey: 'shell.menu.items.slides', Icon: IconBell, to: '/admin/login-slides' },
],
},
]; ];
/** /**
* (App.tsx ). * (App.tsx ).
* id id 'H7' * hallLabel (: "제2전시장 홀7" H7), 'H7' .
* (LoginPage.enterWorkspace ). * ( ).
*
* ( /events/:eventId/exhibitors ).
*/ */
const EVENT_SCOPED_PATH: Record<string, (eventId: string) => string> = { const EVENT_SCOPED_PATH: Record<string, (eventId: string, hallId: string) => string> = {
dashboard: (id) => `/events/${id}/dashboard`, dashboard: (id) => `/events/${id}/dashboard`,
floorplan: (id) => `/events/${id}/halls/H7/layout`, floorplan: (id, hallId) => `/events/${id}/halls/${hallId}/layout`,
exhibitors: (id) => `/events/${id}/dashboard`, exhibitors: (id) => `/events/${id}/dashboard`,
}; };
/** 도메인 모듈(SCR-25~38 이식분) — 백엔드 미구현 모듈은 화면에 "샘플 데이터" 배지 표기. */ /** hallLabel("제2전시장 홀7"·"홀 10" 등) → 홀 id("H7"). 파싱 불가 시 기본 H7. */
const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [ function hallIdFromLabel(label: string | null | undefined): string {
{ key: 'halls', label: '홀 배정', Icon: IconFloorplan, to: '/halls' }, const m = /홀\s*(\d+)/.exec(label ?? '');
{ key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' }, return m ? `H${m[1]}` : 'H7';
{ key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' }, }
{ key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' },
{ key: 'checkin', label: '체크인 데스크', Icon: IconCheckCircle, to: '/visitors/checkin' },
{ key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' },
{ key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' },
{ key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' },
{ key: 'cms', label: '콘텐츠 CMS', Icon: IconDocument, to: '/cms' },
];
/** §5B 공통 업무 기능(SCR-39~48) — 전 역할 공통 영역. */ /** 현재 경로가 속한 대분류 그룹 id(활성 그룹 자동 펼침용). 정적 to 항목만 매칭. */
const WORK_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [ function activeGroupId(pathname: string): string | null {
{ key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' }, for (const g of GROUPS) {
{ key: 'work-schedule', label: '일정', Icon: IconCalendar, to: '/work/schedule' }, for (const it of g.items) {
{ key: 'message', label: '쪽지', Icon: IconUsers, to: '/work/message' }, if (it.to && (pathname === it.to || pathname.startsWith(`${it.to}/`))) return g.id;
{ key: 'notice', label: '공지', Icon: IconBell, to: '/work/notice' }, }
{ key: 'meeting', label: '회의록', Icon: IconOperations, to: '/work/meeting' }, }
{ key: 'report', label: '업무보고', Icon: IconAnalytics, to: '/work/report' }, return null;
{ key: 'opinion', label: '의견접수', Icon: IconCheckCircle, to: '/work/opinion' }, }
{ key: 'search', label: '통합검색', Icon: IconSearch, to: '/work/search' },
];
/** /**
* (design.md §2-2) 240px + (·D-·). * (design.md §2-2 · WISE AppLayout )
* . * ( + + ) + (··D-·).
* nav-collapsed ( / <1024px 오버레이 드로어 + 백드롭).
*/ */
export function AppShell() { export function AppShell() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@ -106,44 +223,75 @@ export function AppShell() {
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const themeResolved = useThemeStore((s) => s.resolved); const themeResolved = useThemeStore((s) => s.resolved);
const toggleTheme = useThemeStore((s) => s.toggle); const toggleTheme = useThemeStore((s) => s.toggle);
const navCollapsed = useUiStore((s) => s.navCollapsed);
const toggleNav = useUiStore((s) => s.toggleNav);
const setNavCollapsed = useUiStore((s) => s.setNavCollapsed);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const openTab = useMdiStore((s) => s.openTab); const openTab = useMdiStore((s) => s.openTab);
const resolvedEventId = useResolvedEventId(); const resolvedEventId = useResolvedEventId();
const isAdmin = useIsAdmin();
const isDark = themeResolved === 'dark'; const isDark = themeResolved === 'dark';
const { t } = useTranslation();
/** 메뉴 라벨 해소 — shell.menu.* i18n(4개 언어), 미해소 시 ko 폴백(label). */
const navLabel = (labelKey: string | undefined, fallback: string) =>
labelKey ? t(labelKey, { defaultValue: fallback }) : fallback;
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
/** /**
* MDI (§2.7): ( · ·) * MDI (§2.7) + .
* . openTab dedup( ) + . * (= ). .
* navigate ( · ) .
*/ */
useEffect(() => { useEffect(() => {
openTab(location.pathname, labelForPath(location.pathname)); openTab(location.pathname, labelForPath(location.pathname));
const active = activeGroupId(location.pathname);
if (active) setOpenGroup(active);
}, [location.pathname, openTab]); }, [location.pathname, openTab]);
/** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */ const toggleGroup = (id: string) => setOpenGroup((prev) => (prev === id ? null : id));
/** 링크 이동 시 모바일이면 드로어 닫기(WISE maybeCloseOnMobile). */
const closeOnMobile = () => {
if (isMobileViewport()) setNavCollapsed(true);
};
/**
* .
* ( · ) /login (2026-07-12 ).
*/
function goScoped(key: string) { function goScoped(key: string) {
const build = EVENT_SCOPED_PATH[key]; const build = EVENT_SCOPED_PATH[key];
if (resolvedEventId && build) { if (resolvedEventId && build) {
navigate(build(resolvedEventId)); const ws = useAuthStore.getState().workspaces.find((w) => w.eventId === resolvedEventId);
navigate(build(resolvedEventId, hallIdFromLabel(ws?.hallLabel)));
} else { } else {
navigate('/login'); // 참여 행사 없음 → 워크스페이스 선택 그리드 navigate('/home');
} }
closeOnMobile();
} }
const visibleGroups = GROUPS.filter((g) => !g.admin || isAdmin);
return ( return (
<div className="kx-shell"> <div className={`kx-shell ${navCollapsed ? 'is-nav-collapsed' : ''}`}>
<a className="kx-skip-link" href="#kx-main-content"> <a className="kx-skip-link" href="#kx-main-content">
</a> </a>
<nav className="kx-shell__sidebar" aria-label="주 메뉴"> <nav className="kx-shell__sidebar" aria-label="주 메뉴">
<div className="kx-shell__brand"> {/* 브랜드 — 실제 KINTEX CI 워드마크(소유자 지시 ①, 다크 대비 화이트 플레이트) */}
<span className="kx-shell__brand-mark" aria-hidden="true">K</span> <NavLink to="/home" className="kx-shell__brand" onClick={closeOnMobile} title="KINTEX AI">
<span className="kx-shell__brand-text"> <img
KINTEX AI src="/brand/kintex-wordmark.jpg"
<small>Exhibition Manager</small> alt="KINTEX"
</span> className="kx-shell__brand-logo"
</div> draggable={false}
/>
<small className="kx-shell__brand-sub">AI </small>
</NavLink>
{workspace && ( {workspace && (
<button className="kx-shell__event-switch" type="button"> <button className="kx-shell__event-switch" type="button">
@ -152,8 +300,50 @@ export function AppShell() {
</button> </button>
)} )}
<div className="kx-shell__nav-scroll">
{/* 홈 — 최상위 단일 링크 */}
<ul className="kx-shell__nav"> <ul className="kx-shell__nav">
{NAV_ITEMS.map((item) => ( <li>
<NavLink
to={HOME_ITEM.to!}
className={({ isActive }) =>
`kx-shell__nav-link kx-shell__nav-link--top ${isActive ? 'is-active' : ''}`
}
onClick={closeOnMobile}
>
<span className="kx-shell__nav-icon">{HOME_ITEM.Icon && <HOME_ITEM.Icon size={18} />}</span>
{navLabel(HOME_ITEM.labelKey, HOME_ITEM.label)}
</NavLink>
</li>
</ul>
{/* 대분류 그룹 아코디언 */}
{visibleGroups.map((group) => {
const expanded = openGroup === group.id;
return (
<div className="kx-shell__group" key={group.id}>
<button
type="button"
className="kx-shell__group-title"
aria-expanded={expanded}
onClick={() => toggleGroup(group.id)}
>
<span className="kx-shell__nav-icon"><group.Icon size={18} /></span>
<span className="kx-shell__group-label">{navLabel(group.labelKey, group.label)}</span>
<IconChevronDown
size={15}
className={`kx-shell__group-caret ${expanded ? 'is-open' : ''}`}
/>
</button>
{expanded && (
<ul className="kx-shell__nav kx-shell__nav--sub">
{group.items.map((item) =>
item.section ? (
// 소그룹 구분 헤더(클릭 불가) — 시스템관리 20항목 정리(WISE 구분선 패턴).
<li key={item.key} className="kx-shell__nav-section" aria-hidden="true">
{navLabel(item.labelKey, item.label)}
</li>
) : (
<li key={item.key}> <li key={item.key}>
{item.to ? ( {item.to ? (
<NavLink <NavLink
@ -161,9 +351,10 @@ export function AppShell() {
className={({ isActive }) => className={({ isActive }) =>
`kx-shell__nav-link ${isActive ? 'is-active' : ''}` `kx-shell__nav-link ${isActive ? 'is-active' : ''}`
} }
onClick={closeOnMobile}
> >
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span> <span className="kx-shell__nav-dot" aria-hidden="true" />
{item.label} {navLabel(item.labelKey, item.label)}
</NavLink> </NavLink>
) : ( ) : (
<button <button
@ -171,62 +362,36 @@ export function AppShell() {
className="kx-shell__nav-link kx-shell__nav-link--btn" className="kx-shell__nav-link kx-shell__nav-link--btn"
onClick={() => goScoped(item.key)} onClick={() => goScoped(item.key)}
> >
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span> <span className="kx-shell__nav-dot" aria-hidden="true" />
{item.label} {navLabel(item.labelKey, item.label)}
</button> </button>
)} )}
</li> </li>
))} ),
</ul> )}
<div className="kx-shell__nav-label"> </div>
<ul className="kx-shell__nav">
{DOMAIN_NAV.map((item) => (
<li key={item.key}>
<NavLink
to={item.to}
className={({ isActive }) => `kx-shell__nav-link ${isActive ? 'is-active' : ''}`}
>
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span>
{item.label}
</NavLink>
</li>
))}
</ul>
<div className="kx-shell__nav-label"> </div>
<ul className="kx-shell__nav">
{WORK_NAV.map((item) => (
<li key={item.key}>
<NavLink
to={item.to}
className={({ isActive }) => `kx-shell__nav-link ${isActive ? 'is-active' : ''}`}
>
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span>
{item.label}
</NavLink>
</li>
))}
</ul> </ul>
)}
</div>
);
})}
</div>
{/* 하단 사용자 영역 — 반응형 재정렬(소유자 지적 3): 프로필 행 + 액션 아이콘 행. */}
<div className="kx-shell__user"> <div className="kx-shell__user">
<button <button
className="kx-shell__avatar kx-shell__avatar--btn" className="kx-shell__user-profile"
type="button" type="button"
aria-label="마이페이지" aria-label="마이페이지"
onClick={() => navigate('/me')} onClick={() => { navigate('/me'); closeOnMobile(); }}
> >
{user?.displayName?.[0] ?? '·'} <span className="kx-shell__avatar" aria-hidden="true">
{user?.displayName?.[0] ?? <IconUser size={16} />}
</span>
<span className="kx-shell__user-name">{user?.displayName ?? '사용자'}</span>
</button> </button>
<div className="kx-shell__user-actions">
<button <button
className="kx-shell__user-name kx-shell__user-name--btn" className="kx-shell__user-action"
type="button"
onClick={() => navigate('/me')}
>
{user?.displayName ?? '사용자'}
</button>
<button
className="kx-shell__theme-toggle"
type="button" type="button"
aria-label={isDark ? '라이트 모드로 전환' : '다크 모드로 전환'} aria-label={isDark ? '라이트 모드로 전환' : '다크 모드로 전환'}
aria-pressed={isDark} aria-pressed={isDark}
@ -235,19 +400,46 @@ export function AppShell() {
> >
{isDark ? <IconSun size={18} /> : <IconMoon size={18} />} {isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
</button> </button>
<LanguageSwitcher variant="dark" /> <LanguageSwitcher variant="light" className="kx-shell__user-lang" />
<button className="kx-shell__logout" type="button" onClick={logout}> <button
className="kx-shell__user-action"
type="button"
aria-label="로그아웃"
title="로그아웃"
onClick={logout}
>
<IconLogOut size={18} />
</button> </button>
</div> </div>
</div>
</nav> </nav>
{/* 모바일 드로어 백드롭 — nav 열림 시에만(데스크톱은 CSS 로 미표시). */}
{!navCollapsed && (
<div
className="kx-shell__backdrop"
onClick={() => setNavCollapsed(true)}
aria-hidden="true"
/>
)}
<div className="kx-shell__main"> <div className="kx-shell__main">
<header className="kx-shell__topbar"> <header className="kx-shell__topbar">
<div className="kx-shell__topbar-left">
<button
type="button"
className="kx-shell__nav-toggle"
aria-label={navCollapsed ? '메뉴 열기' : '메뉴 닫기'}
aria-expanded={!navCollapsed}
onClick={toggleNav}
>
<IconMenu size={20} />
</button>
<div className="kx-shell__crumb"> <div className="kx-shell__crumb">
{workspace?.eventName ?? '워크스페이스'} <span className="kx-shell__crumb-root">{workspace?.eventName ?? '워크스페이스'}</span>
<span className="kx-shell__crumb-sep">/</span> <span className="kx-shell__crumb-sep">/</span>
<span className="kx-shell__crumb-cur">{labelForPath(location.pathname)}</span>
</div>
</div> </div>
<div className="kx-shell__topbar-right"> <div className="kx-shell__topbar-right">
{workspace && <DdayChip dday={workspace.dday} />} {workspace && <DdayChip dday={workspace.dday} />}
@ -268,7 +460,7 @@ export function AppShell() {
<Outlet /> <Outlet />
</main> </main>
{/* 전 셸 화면 공통 하단 상태바 (design.md §2-2, 약 36px) */} {/* 전 셸 화면 공통 하단 상태바 (design.md §2-2) */}
<ShellFooter /> <ShellFooter />
</div> </div>
</div> </div>

View File

@ -1,9 +1,11 @@
/* ══════════ 웹 인증 셸 (WISE AppLayout 구조 이식) ══════════ */
.kx-shell { .kx-shell {
display: flex; display: flex;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
/* ── 사이드바 ── */
.kx-shell__sidebar { .kx-shell__sidebar {
width: var(--sidebar-w); width: var(--sidebar-w);
flex-shrink: 0; flex-shrink: 0;
@ -11,43 +13,49 @@
border-right: var(--border-card); border-right: var(--border-card);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--space-5) 0; min-height: 0;
transition: width 0.2s ease;
}
/* 데스크톱 접힘 — 사이드바 숨김(콘텐츠 전폭). */
.kx-shell.is-nav-collapsed .kx-shell__sidebar {
width: 0;
overflow: hidden;
border-right: none;
} }
/* 브랜드 — 실제 KINTEX CI 워드마크. 다크 모드 대비 화이트 플레이트(JPG 는 알파 없음). */
.kx-shell__brand { .kx-shell__brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
padding: 0 var(--space-5); height: 56px;
margin-bottom: var(--space-5); flex: none;
padding: 0 var(--space-4);
border-bottom: var(--border-card);
text-decoration: none;
overflow: hidden;
} }
.kx-shell__brand-mark { .kx-shell__brand-logo {
width: 32px; height: 22px;
height: 32px; width: auto;
flex: none;
background: #fff;
padding: 3px 7px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--color-primary-600); display: block;
color: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
} }
.kx-shell__brand-text { .kx-shell__brand-sub {
display: flex;
flex-direction: column;
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-primary-700);
line-height: 1.1;
}
.kx-shell__brand-text small {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 600;
color: var(--color-neutral-500); color: var(--color-neutral-500);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* 행사 스위처 */
.kx-shell__event-switch { .kx-shell__event-switch {
margin: 0 var(--space-4) var(--space-5); margin: var(--space-3) var(--space-3) 0;
padding: var(--space-3); padding: var(--space-3);
background: var(--color-neutral-050); background: var(--color-neutral-050);
border: var(--border-card); border: var(--border-card);
@ -56,17 +64,32 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
flex: none;
} }
.kx-shell__event-name { .kx-shell__event-name {
font-size: var(--fs-body); font-size: var(--fs-body);
font-weight: 600; font-weight: 600;
color: var(--color-neutral-900); color: var(--color-neutral-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.kx-shell__event-meta { .kx-shell__event-meta {
font-size: var(--fs-caption); font-size: var(--fs-caption);
color: var(--color-neutral-500); color: var(--color-neutral-500);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* 메뉴 스크롤 영역 — 사이드바 중앙(하단 사용자 고정). */
.kx-shell__nav-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-3) 0 var(--space-4);
}
.kx-shell__nav { .kx-shell__nav {
list-style: none; list-style: none;
margin: 0; margin: 0;
@ -74,17 +97,78 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
flex: 1;
} }
.kx-shell__nav--sub {
padding: 2px 0 var(--space-2);
}
/* 소그룹 구분 헤더(시스템관리 20항목 정리) — 클릭 불가, WISE 구분선 라벨. */
.kx-shell__nav-section {
margin: var(--space-2) 0 2px;
padding: 4px var(--space-4) 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-neutral-500);
border-top: 1px solid var(--color-neutral-100);
}
.kx-shell__nav-section:first-child {
margin-top: 0;
border-top: none;
}
/* 대분류 그룹(아코디언) */
.kx-shell__group {
padding: 0 var(--space-3);
}
.kx-shell__group-title {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: 9px var(--space-4);
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--color-neutral-700);
font-size: var(--fs-body);
font-weight: 600;
text-align: left;
transition: background 0.15s ease, color 0.15s ease;
}
.kx-shell__group-title:hover {
background: var(--color-neutral-050);
color: var(--color-neutral-900);
}
.kx-shell__group-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-shell__group-caret {
flex: none;
color: var(--color-neutral-500);
transition: transform 0.2s ease;
}
.kx-shell__group-caret.is-open {
transform: rotate(180deg);
}
/* 링크 */
.kx-shell__nav-link { .kx-shell__nav-link {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
padding: 10px var(--space-4); width: 100%;
padding: 8px var(--space-4);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--color-neutral-700); color: var(--color-neutral-700);
font-size: var(--fs-body); font-size: var(--fs-body);
font-weight: 500; font-weight: 500;
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
} }
.kx-shell__nav-link:hover { .kx-shell__nav-link:hover {
background: var(--color-neutral-050); background: var(--color-neutral-050);
@ -95,9 +179,11 @@
color: var(--color-primary-700); color: var(--color-primary-700);
font-weight: 600; font-weight: 600;
} }
/* 행사 스코프 진입(대시보드·플로어플랜·참가업체) — button 이지만 nav-link 와 동일 표현. */ .kx-shell__nav-link--top {
font-weight: 600;
}
/* 스코프 진입(button) — nav-link 와 동일 표현. */
.kx-shell__nav-link--btn { .kx-shell__nav-link--btn {
width: 100%;
border: 0; border: 0;
background: transparent; background: transparent;
text-align: left; text-align: left;
@ -105,99 +191,114 @@
font-family: inherit; font-family: inherit;
} }
.kx-shell__nav-icon { .kx-shell__nav-icon {
width: 20px; width: 18px;
flex: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-neutral-500);
}
.kx-shell__nav-link.is-active .kx-shell__nav-icon {
color: var(--color-primary-700);
}
/* 하위 링크 앞 점 마커(들여쓰기 정렬) */
.kx-shell__nav-dot {
width: 5px;
height: 5px;
margin-left: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
flex: none;
}
.kx-shell__nav-link.is-active .kx-shell__nav-dot {
opacity: 1;
background: var(--color-primary-600);
} }
/* ── 하단 사용자 영역 (반응형 재정렬) ── */
.kx-shell__user { .kx-shell__user {
flex: none;
border-top: var(--border-card);
padding: var(--space-3) var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-shell__user-profile {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-4) var(--space-5) 0; width: 100%;
border-top: var(--border-card); min-width: 0;
margin-top: var(--space-3); border: none;
background: transparent;
padding: 6px var(--space-2);
border-radius: var(--radius-sm);
text-align: left;
cursor: pointer;
}
.kx-shell__user-profile:hover {
background: var(--color-neutral-050);
} }
.kx-shell__avatar { .kx-shell__avatar {
width: 28px; width: 30px;
height: 28px; height: 30px;
flex: none;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary-100); background: var(--color-primary-100);
color: var(--color-primary-700); color: var(--color-primary-700);
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
} }
.kx-shell__user-name { .kx-shell__user-name {
flex: 1; flex: 1 1 auto;
font-size: var(--fs-caption); min-width: 0;
font-weight: 500; font-size: var(--fs-body);
color: var(--color-neutral-700); font-weight: 600;
color: var(--color-neutral-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.kx-shell__theme-toggle { .kx-shell__user-actions {
width: 28px; display: flex;
height: 28px; align-items: center;
flex-shrink: 0; gap: var(--space-1);
border-radius: 50%; }
border: var(--border-card); .kx-shell__user-action {
background: var(--color-neutral-050); width: 34px;
height: 34px;
flex: none;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-neutral-500); color: var(--color-neutral-500);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
} }
.kx-shell__theme-toggle:hover { .kx-shell__user-action:hover {
color: var(--color-primary-600); background: var(--color-neutral-050);
background: var(--color-neutral-100);
}
.kx-shell__logout {
background: none;
border: none;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-shell__logout:hover {
color: var(--color-primary-600); color: var(--color-primary-600);
} }
/* 언어 셀렉트 — 액션 행에서 가운데 신축(줄바꿈/오버플로 방지). */
/* §5B 업무 공통 2번째 내비 그룹 — 콘텐츠 높이만 차지, 사이드바 스크롤. */ .kx-shell__user-lang {
.kx-shell__sidebar { flex: 1 1 auto;
overflow-y: auto; justify-content: center;
} overflow: hidden;
.kx-shell__nav {
flex: 0 0 auto;
}
.kx-shell__nav-label {
padding: var(--space-4) var(--space-5) var(--space-2);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-neutral-500);
text-transform: uppercase;
}
.kx-shell__user {
margin-top: auto;
}
.kx-shell__avatar--btn {
border: none;
cursor: pointer;
}
.kx-shell__user-name--btn {
background: none;
border: none;
text-align: left;
cursor: pointer;
padding: 0;
}
.kx-shell__user-name--btn:hover {
color: var(--color-primary-600);
} }
/* ── 모바일 드로어 백드롭(데스크톱 미표시) ── */
.kx-shell__backdrop {
display: none;
}
/* ══════════ 메인 컬럼 ══════════ */
.kx-shell__main { .kx-shell__main {
flex: 1; flex: 1;
display: flex; display: flex;
@ -212,22 +313,63 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4);
padding: 0 var(--gutter); padding: 0 var(--gutter);
} }
.kx-shell__topbar-left {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1 1 auto;
min-width: 0;
}
.kx-shell__nav-toggle {
width: 38px;
height: 38px;
flex: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-neutral-500);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease;
}
.kx-shell__nav-toggle:hover {
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
.kx-shell__crumb { .kx-shell__crumb {
font-size: var(--fs-body); font-size: var(--fs-body);
color: var(--color-neutral-500); color: var(--color-neutral-500);
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-width: 0;
}
.kx-shell__crumb-root {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
} }
.kx-shell__crumb-sep { .kx-shell__crumb-sep {
color: var(--color-neutral-200); color: var(--color-neutral-200);
flex: none;
}
.kx-shell__crumb-cur {
color: var(--color-neutral-900);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.kx-shell__topbar-right { .kx-shell__topbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
flex: none;
} }
.kx-shell__icon-btn { .kx-shell__icon-btn {
width: 32px; width: 32px;
@ -251,7 +393,7 @@
display: flex; display: flex;
} }
/* ── 하단 상태바/푸터 (design.md §2-2, 약 36px) — 사이드바 우측 전폭·콘텐츠 아래 고정 ── */ /* ── 하단 상태바/푸터 (design.md §2-2, 약 36px) ── */
.kx-shell__footer { .kx-shell__footer {
flex-shrink: 0; flex-shrink: 0;
height: 36px; height: 36px;
@ -337,7 +479,6 @@
.kx-mdi__tab:hover { .kx-mdi__tab:hover {
background: var(--color-neutral-100); background: var(--color-neutral-100);
} }
/* 활성 탭 — 흰 서피스 + 상단 2px primary 인디케이터(§2.7-2). */
.kx-mdi__tab.is-active { .kx-mdi__tab.is-active {
background: var(--color-white); background: var(--color-white);
border-color: var(--color-neutral-200); border-color: var(--color-neutral-200);
@ -384,9 +525,48 @@
color: var(--color-neutral-900); color: var(--color-neutral-900);
} }
/* ══════════ 반응형 ══════════ */
/* <1024px — 사이드바를 오버레이 드로어로. 접힘 시 화면 밖으로 슬라이드. */
@media (max-width: 1024px) {
.kx-shell__sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-w);
z-index: 40;
box-shadow: var(--shadow-level2);
transform: translateX(0);
transition: transform 0.2s ease;
}
.kx-shell.is-nav-collapsed .kx-shell__sidebar {
width: var(--sidebar-w);
overflow: hidden;
border-right: var(--border-card);
transform: translateX(-100%);
}
.kx-shell__backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(8, 14, 24, 0.5);
z-index: 35;
}
}
/* 모바일 폭에서는 탭 스트립 숨김(단일 화면 포커스 — §2.7-6). */ /* 모바일 폭에서는 탭 스트립 숨김(단일 화면 포커스 — §2.7-6). */
@media (max-width: 720px) { @media (max-width: 720px) {
.kx-mdi { .kx-mdi {
display: none; display: none;
} }
} }
@media (max-width: 640px) {
.kx-shell__crumb-root,
.kx-shell__crumb-sep {
display: none;
}
.kx-shell__topbar {
padding: 0 var(--space-4);
}
}

View File

@ -468,6 +468,27 @@ export function IconMoon(p: IconProps) {
); );
} }
/** ⎋ 로그아웃(문 + 화살표). */
export function IconLogOut(p: IconProps) {
return (
<Svg {...p}>
<path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" {...SRJ} />
<path d="M16 17L21 12L16 7" {...SRJ} />
<path d="M21 12H9" {...SR} />
</Svg>
);
}
/** ○ 사용자(마이페이지 아바타 폴백). */
export function IconUser(p: IconProps) {
return (
<Svg {...p}>
<circle cx="12" cy="8" r="3.6" {...S} />
<path d="M5 20C5 16.4 8.1 13.6 12 13.6C15.9 13.6 19 16.4 19 20" {...SR} />
</Svg>
);
}
/** ● 오프라인(연결 끊김). */ /** ● 오프라인(연결 끊김). */
export function IconWifiOff(p: IconProps) { export function IconWifiOff(p: IconProps) {
return ( return (

View File

@ -31,6 +31,75 @@
"select": "Select language" "select": "Select language"
}, },
"shell": { "shell": {
"menu": {
"home": "Home",
"groups": {
"ops": "Exhibition Ops",
"design": "Design & Build",
"visitor": "Visitors & Marketing",
"finance": "Settlement & Analytics",
"work": "Workspace",
"system": "System Admin"
},
"items": {
"dashboard": "Dashboard",
"exhibitors": "Exhibitors",
"schedule": "Schedule",
"halls": "Hall Assignment",
"boothSales": "Booth Sales",
"operations": "Onsite Ops",
"floorplan": "Floorplan Studio",
"documents": "Docs & Milestones",
"auctions": "Construction Auction",
"contractor": "Contractor Booths",
"logistics": "Move In/Out",
"visitors": "Visitors",
"checkin": "Check-in Desk",
"leads": "Leads",
"campaigns": "Marketing",
"sponsorship": "Sponsorship",
"cms": "Content CMS",
"settlement": "Settlement",
"analytics": "Analytics",
"worklog": "Work Log",
"workSchedule": "Schedule",
"message": "Messages",
"notice": "Notices",
"meeting": "Meetings",
"report": "Reports",
"approval": "Approvals",
"opinion": "Feedback",
"search": "Search",
"appqr": "Mobile App",
"admin": "Admin Dashboard",
"users": "Users",
"roles": "Roles & Permissions",
"depts": "Departments",
"companies": "Companies",
"menus": "Menus",
"programs": "Programs",
"roleMenus": "Role-Menu Mapping",
"codes": "Common Codes",
"authPolicy": "Auth Policy",
"loginHistory": "Login History",
"audit": "Audit Log",
"errorLog": "Error Log",
"systemHealth": "System Health",
"settings": "System Settings",
"mailConfig": "Mail Config",
"notifyConfig": "Notify Config",
"tenants": "Tenants",
"rulesets": "Rulesets",
"slides": "Login Slides"
},
"sections": {
"userOrg": "Users & Org",
"menuAuth": "Menus & Codes",
"policyLog": "Policy & Logs",
"statusConfig": "Status & Config",
"masterEtc": "Master & Others"
}
},
"nav": { "nav": {
"events": "Events", "events": "Events",
"exhibit": "Exhibitors", "exhibit": "Exhibitors",

View File

@ -31,6 +31,75 @@
"select": "言語を選択" "select": "言語を選択"
}, },
"shell": { "shell": {
"menu": {
"home": "ホーム",
"groups": {
"ops": "展示運営",
"design": "設計・施工",
"visitor": "来場者・マーケティング",
"finance": "精算・分析",
"work": "業務共通",
"system": "システム管理"
},
"items": {
"dashboard": "ダッシュボード",
"exhibitors": "出展社管理",
"schedule": "展示日程",
"halls": "ホール割当",
"boothSales": "ブース販売",
"operations": "現場運営",
"floorplan": "フロアプランスタジオ",
"documents": "書類・マイルストーン",
"auctions": "工事オークション",
"contractor": "受注ブース",
"logistics": "搬入・搬出",
"visitors": "来場者",
"checkin": "チェックインデスク",
"leads": "リード",
"campaigns": "マーケティング",
"sponsorship": "スポンサーシップ",
"cms": "コンテンツCMS",
"settlement": "精算",
"analytics": "経営分析",
"worklog": "業務日誌",
"workSchedule": "日程",
"message": "メッセージ",
"notice": "お知らせ",
"meeting": "議事録",
"report": "業務報告",
"approval": "決裁",
"opinion": "意見受付",
"search": "統合検索",
"appqr": "モバイルアプリ",
"admin": "管理ダッシュボード",
"users": "ユーザー管理",
"roles": "役割/権限",
"depts": "組織管理",
"companies": "会社管理",
"menus": "メニュー管理",
"programs": "プログラム管理",
"roleMenus": "役割-メニュー割当",
"codes": "共通コード",
"authPolicy": "認証ポリシー",
"loginHistory": "ログイン履歴",
"audit": "監査ログ",
"errorLog": "エラーログ",
"systemHealth": "システム状態",
"settings": "システム設定",
"mailConfig": "メール設定",
"notifyConfig": "通知設定",
"tenants": "テナント",
"rulesets": "ルールセット",
"slides": "ログインスライド"
},
"sections": {
"userOrg": "ユーザー・組織",
"menuAuth": "メニュー・コード",
"policyLog": "ポリシー・ログ",
"statusConfig": "状態・設定",
"masterEtc": "マスター・その他"
}
},
"nav": { "nav": {
"events": "イベント", "events": "イベント",
"exhibit": "出展案内", "exhibit": "出展案内",

View File

@ -31,6 +31,75 @@
"select": "언어 선택" "select": "언어 선택"
}, },
"shell": { "shell": {
"menu": {
"home": "홈",
"groups": {
"ops": "전시 운영",
"design": "설계·시공",
"visitor": "관람객·마케팅",
"finance": "정산·분석",
"work": "업무 공통",
"system": "시스템관리"
},
"items": {
"dashboard": "대시보드",
"exhibitors": "참가업체 관리",
"schedule": "전시 일정",
"halls": "홀 배정",
"boothSales": "부스 판매",
"operations": "현장운영",
"floorplan": "플로어플랜 스튜디오",
"documents": "서류·마일스톤",
"auctions": "공사 옥션",
"contractor": "수주 부스",
"logistics": "반입·반출",
"visitors": "관람객",
"checkin": "체크인 데스크",
"leads": "리드",
"campaigns": "마케팅",
"sponsorship": "스폰서십",
"cms": "콘텐츠 CMS",
"settlement": "정산",
"analytics": "경영분석",
"worklog": "업무일지",
"workSchedule": "일정",
"message": "쪽지",
"notice": "공지",
"meeting": "회의록",
"report": "업무보고",
"approval": "결재",
"opinion": "의견접수",
"search": "통합검색",
"appqr": "모바일 앱 설치",
"admin": "관리 대시보드",
"users": "사용자 관리",
"roles": "역할/권한",
"depts": "조직 관리",
"companies": "회사 관리",
"menus": "메뉴 관리",
"programs": "프로그램 관리",
"roleMenus": "역할-메뉴 매핑",
"codes": "공통코드",
"authPolicy": "인증 정책",
"loginHistory": "로그인 이력",
"audit": "감사로그",
"errorLog": "오류 로그",
"systemHealth": "시스템 상태",
"settings": "시스템설정",
"mailConfig": "메일 설정",
"notifyConfig": "알림 설정",
"tenants": "테넌트",
"rulesets": "룰셋",
"slides": "로그인 슬라이드"
},
"sections": {
"userOrg": "사용자·조직",
"menuAuth": "메뉴·프로그램·코드",
"policyLog": "정책·이력·로그",
"statusConfig": "상태·설정",
"masterEtc": "마스터·기타"
}
},
"nav": { "nav": {
"events": "행사", "events": "행사",
"exhibit": "참가안내", "exhibit": "참가안내",

View File

@ -31,6 +31,75 @@
"select": "选择语言" "select": "选择语言"
}, },
"shell": { "shell": {
"menu": {
"home": "首页",
"groups": {
"ops": "展会运营",
"design": "设计·施工",
"visitor": "观众·营销",
"finance": "结算·分析",
"work": "通用业务",
"system": "系统管理"
},
"items": {
"dashboard": "仪表板",
"exhibitors": "参展商管理",
"schedule": "展会日程",
"halls": "展厅分配",
"boothSales": "展位销售",
"operations": "现场运营",
"floorplan": "平面图工作室",
"documents": "文件·里程碑",
"auctions": "施工竞标",
"contractor": "中标展位",
"logistics": "进场·退场",
"visitors": "观众",
"checkin": "签到台",
"leads": "潜在客户",
"campaigns": "营销",
"sponsorship": "赞助",
"cms": "内容CMS",
"settlement": "结算",
"analytics": "经营分析",
"worklog": "工作日志",
"workSchedule": "日程",
"message": "便签",
"notice": "公告",
"meeting": "会议记录",
"report": "工作报告",
"approval": "审批",
"opinion": "意见收集",
"search": "综合搜索",
"appqr": "移动应用安装",
"admin": "管理仪表板",
"users": "用户管理",
"roles": "角色/权限",
"depts": "组织管理",
"companies": "公司管理",
"menus": "菜单管理",
"programs": "程序管理",
"roleMenus": "角色-菜单映射",
"codes": "公共代码",
"authPolicy": "认证策略",
"loginHistory": "登录历史",
"audit": "审计日志",
"errorLog": "错误日志",
"systemHealth": "系统状态",
"settings": "系统设置",
"mailConfig": "邮件设置",
"notifyConfig": "通知设置",
"tenants": "租户",
"rulesets": "规则集",
"slides": "登录幻灯片"
},
"sections": {
"userOrg": "用户·组织",
"menuAuth": "菜单·代码",
"policyLog": "策略·日志",
"statusConfig": "状态·设置",
"masterEtc": "主数据·其他"
}
},
"nav": { "nav": {
"events": "展会", "events": "展会",
"exhibit": "参展指南", "exhibit": "参展指南",

View File

@ -0,0 +1,120 @@
/*
* W12 (· ·).
* 정본: GET/PUT /api/admin/auth-policy AuthPolicy (AuthPolicyController). .
* LoginAttemptService(login.*) ( ).
*/
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { errMessage, useToast } from '../work/workShared';
import { authPolicyApi, type AuthPolicy, type AuthPolicySaveBody } from './systemBasicsApi';
import './admin-modules.css';
export function AuthPolicyPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const q = useQuery({ queryKey: ['admin-auth-policy'], queryFn: authPolicyApi.get, retry: false });
const [form, setForm] = useState<AuthPolicy | null>(null);
useEffect(() => {
if (q.data) setForm(q.data);
}, [q.data]);
const saveM = useMutation({
mutationFn: (body: AuthPolicySaveBody) => authPolicyApi.save(body),
onSuccess: () => {
show(t('authPolicy.saved'));
qc.invalidateQueries({ queryKey: ['admin-auth-policy'] });
},
onError: (e) => show(errMessage(e)),
});
function num(key: keyof AuthPolicy, v: string) {
setForm((f) => (f ? { ...f, [key]: Number(v) } : f));
}
function yn(key: keyof AuthPolicy, checked: boolean) {
setForm((f) => (f ? { ...f, [key]: checked ? 'Y' : 'N' } : f));
}
function submit(e: React.FormEvent) {
e.preventDefault();
if (!form) return;
const { updatedAt: _a, updatedBy: _b, ...body } = form;
saveM.mutate(body);
}
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('authPolicy.title')}</h1>
<p className="kx-adm__subtitle">{t('authPolicy.subtitle')}</p>
</div>
</header>
{q.isLoading && <Skeleton height={360} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('authPolicy.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && form && (
<form className="kx-card kx-adm-form" onSubmit={submit}>
<h2 className="kx-adm-form__section">{t('authPolicy.pwSection')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('authPolicy.pwMinLength')}</span>
<input className="kx-adm-field__input" type="number" min={4} max={64}
value={form.pwMinLength} onChange={(e) => num('pwMinLength', e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('authPolicy.pwExpireDays')}</span>
<input className="kx-adm-field__input" type="number" min={0} max={3650}
value={form.pwExpireDays} onChange={(e) => num('pwExpireDays', e.target.value)} />
</label>
<label className="kx-adm-check">
<input type="checkbox" checked={form.pwRequireUpper === 'Y'}
onChange={(e) => yn('pwRequireUpper', e.target.checked)} />
<span>{t('authPolicy.pwRequireUpper')}</span>
</label>
<label className="kx-adm-check">
<input type="checkbox" checked={form.pwRequireNumber === 'Y'}
onChange={(e) => yn('pwRequireNumber', e.target.checked)} />
<span>{t('authPolicy.pwRequireNumber')}</span>
</label>
<label className="kx-adm-check">
<input type="checkbox" checked={form.pwRequireSpecial === 'Y'}
onChange={(e) => yn('pwRequireSpecial', e.target.checked)} />
<span>{t('authPolicy.pwRequireSpecial')}</span>
</label>
</div>
<h2 className="kx-adm-form__section">{t('authPolicy.lockSection')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('authPolicy.loginMaxFail')}</span>
<input className="kx-adm-field__input" type="number" min={1} max={20}
value={form.loginMaxFail} onChange={(e) => num('loginMaxFail', e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('authPolicy.lockMinutes')}</span>
<input className="kx-adm-field__input" type="number" min={1} max={1440}
value={form.lockMinutes} onChange={(e) => num('lockMinutes', e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('authPolicy.sessionTimeoutMin')}</span>
<input className="kx-adm-field__input" type="number" min={5} max={1440}
value={form.sessionTimeoutMin} onChange={(e) => num('sessionTimeoutMin', e.target.value)} />
</label>
</div>
<div className="kx-adm-modal__actions">
<Button type="submit" disabled={saveM.isPending}>
{saveM.isPending ? t('sysadmin.saving') : t('sysadmin.save')}
</Button>
</div>
</form>
)}
{toast}
</div>
);
}

View File

@ -0,0 +1,232 @@
/*
* W12 () .
* 정본: GET/POST /api/admin/companies · DELETE /{id} (CompanyController). PageResponse, .
* 보안: 목록의 contactTel .
* (= ) .
*/
import { 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 { Button } from '../../components/ui/Button';
import { IconPlus, IconSearch } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared';
import { companyApi, PARTNER_TYPES, type Company, type CompanySaveBody } from './systemBasicsApi';
import { Pager } from './LoginHistoryPage';
import './admin-modules.css';
export function CompanyAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const [keyword, setKeyword] = useState('');
const [partnerType, setPartnerType] = useState('');
const [applied, setApplied] = useState<{ keyword: string; partnerType: string }>({ keyword: '', partnerType: '' });
const [page, setPage] = useState(0);
const size = 20;
const [editing, setEditing] = useState<Company | null | undefined>(undefined);
const q = useQuery({
queryKey: ['admin-companies', applied, page],
queryFn: () => companyApi.list({ ...applied, page, size }),
retry: false,
});
const rows = q.data?.items ?? [];
const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / size));
const saveM = useMutation({
mutationFn: (body: CompanySaveBody) => companyApi.save(body),
onSuccess: () => { show(t('company.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-companies'] }); },
onError: (e) => show(errMessage(e)),
});
const delM = useMutation({
mutationFn: (id: string) => companyApi.remove(id),
onSuccess: () => { show(t('company.deleted')); qc.invalidateQueries({ queryKey: ['admin-companies'] }); },
onError: (e) => show(errMessage(e)),
});
function apply() { setPage(0); setApplied({ keyword, partnerType }); }
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('company.title')}</h1>
<p className="kx-adm__subtitle">{t('company.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('company.add')}</Button>
</header>
<section className="kx-card kx-adm-filter">
<div className="kx-adm-filter__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('sysadmin.keyword')}</span>
<input className="kx-adm-field__input" value={keyword} onChange={(e) => setKeyword(e.target.value)}
placeholder={t('company.searchPh')} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.partnerType')}</span>
<select className="kx-adm-field__select" value={partnerType} onChange={(e) => setPartnerType(e.target.value)}>
<option value="">{t('company.allTypes')}</option>
{PARTNER_TYPES.map((x) => <option key={x} value={x}>{t(`company.pt_${x}`)}</option>)}
</select>
</label>
<div className="kx-adm-filter__actions">
<Button leadingIcon={<IconSearch size={16} />} onClick={apply}>{t('sysadmin.search')}</Button>
</div>
</div>
</section>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('company.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card">
{rows.length === 0 ? (
<EmptyState title={t('company.emptyTitle')} description={t('company.emptyDesc')} />
) : (
<>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th>{t('company.colName')}</th>
<th>{t('company.colBizNo')}</th>
<th>{t('company.colType')}</th>
<th>{t('company.colContact')}</th>
<th>{t('company.colTel')}</th>
<th>{t('company.colUse')}</th>
<th aria-label={t('sysadmin.actions')} />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.companyName}</td>
<td className="kx-adm-mono">{r.bizNo ?? '-'}</td>
<td>{r.partnerType ? t(`company.pt_${r.partnerType}`) : '-'}</td>
<td>{r.contactName ?? '-'}</td>
<td className="kx-adm-mono">{r.contactTel ?? '-'}</td>
<td>{r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')}</td>
<td className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(r)}>{t('sysadmin.edit')}</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('company.deleteConfirm', { name: r.companyName }))) delM.mutate(r.id); }}>
{t('sysadmin.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager page={page} totalPages={totalPages} total={total} onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} />
</>
)}
</section>
)}
{editing !== undefined && (
<CompanyModal company={editing} saving={saveM.isPending}
onClose={() => setEditing(undefined)} onSubmit={(b) => saveM.mutate(b)} />
)}
{toast}
</div>
);
}
function CompanyModal({ company, saving, onClose, onSubmit }: {
company: Company | null; saving: boolean; onClose: () => void; onSubmit: (b: CompanySaveBody) => void;
}) {
const { t } = useTranslation();
const isNew = company === null;
const [companyName, setName] = useState(company?.companyName ?? '');
const [bizNo, setBizNo] = useState(company?.bizNo ?? '');
const [ceoName, setCeo] = useState(company?.ceoName ?? '');
const [bizType, setBizType] = useState(company?.bizType ?? '');
const [contactName, setContact] = useState(company?.contactName ?? '');
const [contactTel, setTel] = useState(''); // 마스킹 방지: 편집 시 공란 시작
const [address, setAddress] = useState(company?.address ?? '');
const [partnerType, setPartner] = useState(company?.partnerType ?? '');
const [useYn, setUseYn] = useState(company?.useYn ?? 'Y');
const [error, setError] = useState<string | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!companyName.trim()) { setError(t('company.errRequired')); return; }
onSubmit({
id: company?.id ?? null,
companyName: companyName.trim(),
bizNo: bizNo.trim() || null,
ceoName: ceoName.trim() || null,
bizType: bizType.trim() || null,
contactName: contactName.trim() || null,
contactTel: contactTel.trim() || null,
address: address.trim() || null,
partnerType: partnerType || null,
useYn,
});
}
return (
<div className="kx-adm-modal" role="dialog" aria-modal="true">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 className="kx-adm-modal__title">{isNew ? t('company.add') : t('company.edit')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('company.colName')}</span>
<input className="kx-adm-field__input" value={companyName} onChange={(e) => setName(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.colBizNo')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={bizNo} onChange={(e) => setBizNo(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.ceo')}</span>
<input className="kx-adm-field__input" value={ceoName} onChange={(e) => setCeo(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.partnerType')}</span>
<select className="kx-adm-field__select" value={partnerType} onChange={(e) => setPartner(e.target.value)}>
<option value="">-</option>
{PARTNER_TYPES.map((x) => <option key={x} value={x}>{t(`company.pt_${x}`)}</option>)}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.bizType')}</span>
<input className="kx-adm-field__input" value={bizType} onChange={(e) => setBizType(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.contact')}</span>
<input className="kx-adm-field__input" value={contactName} onChange={(e) => setContact(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('company.tel')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={contactTel} onChange={(e) => setTel(e.target.value)}
placeholder={isNew ? '' : t('company.telEditHint')} />
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('company.address')}</span>
<input className="kx-adm-field__input" value={address} onChange={(e) => setAddress(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('sysadmin.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y">{t('sysadmin.use')}</option>
<option value="N">{t('sysadmin.noUse')}</option>
</select>
</label>
</div>
{!isNew && <p className="kx-adm-modal__desc">{t('company.telMaskNote')}</p>}
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>{t('sysadmin.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('sysadmin.saving') : t('sysadmin.save')}</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,150 @@
/*
* W12 ( ) .
* 정본: GET/POST /api/admin/depts · DELETE /{id} (DeptController). (children ), upsert .
*/
import { 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 { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared';
import { deptApi, type DeptNode, type DeptSaveBody } from './systemBasicsApi';
import './admin-modules.css';
interface FlatDept { node: DeptNode; depth: number }
function flatten(nodes: DeptNode[], depth: number, out: FlatDept[]) {
for (const n of nodes) {
out.push({ node: n, depth });
if (n.children && n.children.length) flatten(n.children, depth + 1, out);
}
}
export function DeptAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const q = useQuery({ queryKey: ['admin-depts'], queryFn: deptApi.tree, retry: false });
const [editing, setEditing] = useState<{ node: DeptNode | null; parentId: string | null } | undefined>(undefined);
const tree = q.data ?? [];
const flat = useMemo(() => { const o: FlatDept[] = []; flatten(tree, 0, o); return o; }, [tree]);
const saveM = useMutation({
mutationFn: (body: DeptSaveBody) => deptApi.save(body),
onSuccess: () => { show(t('dept.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-depts'] }); },
onError: (e) => show(errMessage(e)),
});
const delM = useMutation({
mutationFn: (id: string) => deptApi.remove(id),
onSuccess: () => { show(t('dept.deleted')); qc.invalidateQueries({ queryKey: ['admin-depts'] }); },
onError: (e) => show(errMessage(e)),
});
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('dept.title')}</h1>
<p className="kx-adm__subtitle">{t('dept.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing({ node: null, parentId: null })}>
{t('dept.addRoot')}
</Button>
</header>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('dept.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card">
{flat.length === 0 ? (
<EmptyState title={t('dept.emptyTitle')} description={t('dept.emptyDesc')} />
) : (
<ul className="kx-adm-tree">
{flat.map(({ node, depth }) => (
<li key={node.id} className="kx-adm-tree__row" style={{ paddingLeft: 12 + depth * 20 }}>
<span className="kx-adm-tree__name">
{node.deptName}
{node.useYn === 'N' && <span className="kx-adm-muted"> · {t('sysadmin.noUse')}</span>}
</span>
<span className="kx-adm-tree__actions">
<Button variant="ghost" onClick={() => setEditing({ node: null, parentId: node.id })}>{t('dept.addChild')}</Button>
<Button variant="ghost" onClick={() => setEditing({ node, parentId: node.parentId })}>{t('sysadmin.edit')}</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('dept.deleteConfirm', { name: node.deptName }))) delM.mutate(node.id); }}>
{t('sysadmin.delete')}
</Button>
</span>
</li>
))}
</ul>
)}
</section>
)}
{editing !== undefined && (
<DeptModal node={editing.node} parentId={editing.parentId} saving={saveM.isPending}
onClose={() => setEditing(undefined)} onSubmit={(b) => saveM.mutate(b)} />
)}
{toast}
</div>
);
}
function DeptModal({ node, parentId, saving, onClose, onSubmit }: {
node: DeptNode | null; parentId: string | null; saving: boolean;
onClose: () => void; onSubmit: (b: DeptSaveBody) => void;
}) {
const { t } = useTranslation();
const isNew = node === null;
const [deptName, setName] = useState(node?.deptName ?? '');
const [sortOrder, setSort] = useState(node?.sortOrder ?? 0);
const [useYn, setUseYn] = useState(node?.useYn ?? 'Y');
const [error, setError] = useState<string | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!deptName.trim()) { setError(t('dept.errRequired')); return; }
onSubmit({
id: node?.id ?? null,
deptName: deptName.trim(),
parentId: node ? node.parentId : parentId,
sortOrder,
useYn,
});
}
return (
<div className="kx-adm-modal" role="dialog" aria-modal="true">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 className="kx-adm-modal__title">{isNew ? t('dept.add') : t('dept.edit')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('dept.name')}</span>
<input className="kx-adm-field__input" value={deptName} onChange={(e) => setName(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('sysadmin.sortOrder')}</span>
<input className="kx-adm-field__input" type="number" value={sortOrder}
onChange={(e) => setSort(Number(e.target.value))} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('sysadmin.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y">{t('sysadmin.use')}</option>
<option value="N">{t('sysadmin.noUse')}</option>
</select>
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>{t('sysadmin.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('sysadmin.saving') : t('sysadmin.save')}</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,98 @@
/*
* W12 ().
* 정본: GET /api/admin/error-log?errorCode&page&size PageResponse<ErrorLogRow> (ErrorLogController).
* 보안: summary 1 /(· , §0-3).
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { IconSearch } from '../../components/ui/icons';
import { fmtDateTime } from '../work/workShared';
import { errorLogApi } from './systemBasicsApi';
import { Pager } from './LoginHistoryPage';
import './admin-modules.css';
export function ErrorLogPage() {
const { t } = useTranslation();
const [errorCode, setErrorCode] = useState('');
const [applied, setApplied] = useState('');
const [page, setPage] = useState(0);
const size = 20;
const q = useQuery({
queryKey: ['admin-error-log', applied, page],
queryFn: () => errorLogApi.list(applied, page, size),
retry: false,
});
const rows = q.data?.items ?? [];
const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / size));
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('errorLog.title')}</h1>
<p className="kx-adm__subtitle">{t('errorLog.subtitle')}</p>
</div>
</header>
<section className="kx-card kx-adm-filter" aria-label={t('errorLog.filterAria')}>
<div className="kx-adm-filter__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('errorLog.code')}</span>
<input className="kx-adm-field__input" value={errorCode} onChange={(e) => setErrorCode(e.target.value)}
placeholder={t('errorLog.codePh')} />
</label>
<div className="kx-adm-filter__actions">
<Button leadingIcon={<IconSearch size={16} />} onClick={() => { setPage(0); setApplied(errorCode); }}>
{t('sysadmin.search')}
</Button>
</div>
</div>
</section>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('errorLog.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card">
{rows.length === 0 ? (
<EmptyState title={t('errorLog.emptyTitle')} description={t('errorLog.emptyDesc')} />
) : (
<>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th>{t('errorLog.colTime')}</th>
<th>{t('errorLog.colCode')}</th>
<th>{t('errorLog.colMethod')}</th>
<th>{t('errorLog.colPath')}</th>
<th>{t('errorLog.colSummary')}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="kx-adm-mono">{fmtDateTime(r.createdAt)}</td>
<td><span className="kx-adm-pill kx-adm-pill--error">{r.errorCode ?? '-'}</span></td>
<td className="kx-adm-mono">{r.method ?? '-'}</td>
<td className="kx-adm-mono">{r.path ?? '-'}</td>
<td className="kx-adm-summary">{r.summary ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager page={page} totalPages={totalPages} total={total} onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} />
</>
)}
</section>
)}
</div>
);
}

View File

@ -0,0 +1,115 @@
/*
* W12 / (sys_setting mail./notify. ).
* 정본: GET/PUT /api/admin/mail-config · /api/admin/notify-config (MailConfigController/NotifyConfigController).
* 보안: secretYn='Y' (********) ( ).
*/
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { errMessage, useToast } from '../work/workShared';
import {
CONFIG_MASK, mailConfigApi, notifyConfigApi,
type InfraConfigRow, type InfraConfigUpdateBody,
} from './systemBasicsApi';
import './admin-modules.css';
type Variant = 'mail' | 'notify';
function InfraConfig({ variant }: { variant: Variant }) {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const apiObj = variant === 'mail' ? mailConfigApi : notifyConfigApi;
const titleKey = variant === 'mail' ? 'mailConfig' : 'notifyConfig';
const q = useQuery({ queryKey: ['admin-infra', variant], queryFn: apiObj.list, retry: false });
const [values, setValues] = useState<Record<string, string>>({});
useEffect(() => {
if (q.data) {
const v: Record<string, string> = {};
for (const r of q.data) v[r.settingKey] = r.settingValue ?? '';
setValues(v);
}
}, [q.data]);
const saveM = useMutation({
mutationFn: (body: InfraConfigUpdateBody) => apiObj.save(body),
onSuccess: () => { show(t(`${titleKey}.saved`)); qc.invalidateQueries({ queryKey: ['admin-infra', variant] }); },
onError: (e) => show(errMessage(e)),
});
function submit(e: React.FormEvent) {
e.preventDefault();
const rows = q.data ?? [];
const items = rows.map((r) => ({ settingKey: r.settingKey, settingValue: values[r.settingKey] ?? '' }));
saveM.mutate({ items });
}
const rows = q.data ?? [];
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t(`${titleKey}.title`)}</h1>
<p className="kx-adm__subtitle">{t(`${titleKey}.subtitle`)}</p>
</div>
</header>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t(`${titleKey}.loadError`)} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && (
<form className="kx-card kx-adm-form" onSubmit={submit}>
<div className="kx-adm-config-list">
{rows.map((r) => (
<ConfigField key={r.settingKey} row={r} value={values[r.settingKey] ?? ''}
onChange={(v) => setValues((s) => ({ ...s, [r.settingKey]: v }))} />
))}
</div>
<div className="kx-adm-modal__actions">
<Button type="submit" disabled={saveM.isPending}>
{saveM.isPending ? t('sysadmin.saving') : t('sysadmin.save')}
</Button>
</div>
</form>
)}
{toast}
</div>
);
}
function ConfigField({ row, value, onChange }: {
row: InfraConfigRow; value: string; onChange: (v: string) => void;
}) {
const isBool = (row.valueType ?? '').toUpperCase() === 'BOOL';
const isSecret = row.secretYn === 'Y';
return (
<label className="kx-adm-config-field">
<span className="kx-adm-config-field__key">
<span className="kx-adm-mono">{row.settingKey}</span>
{row.description && <span className="kx-adm-config-field__desc">{row.description}</span>}
</span>
{isBool ? (
<select className="kx-adm-field__select" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="true">ON</option>
<option value="false">OFF</option>
</select>
) : (
<input className={`kx-adm-field__input ${isSecret ? '' : 'kx-adm-mono'}`}
type={isSecret && value !== CONFIG_MASK ? 'password' : 'text'}
value={value} onChange={(e) => onChange(e.target.value)} />
)}
</label>
);
}
export function MailConfigPage() {
return <InfraConfig variant="mail" />;
}
export function NotifyConfigPage() {
return <InfraConfig variant="notify" />;
}

View File

@ -0,0 +1,137 @@
/*
* W12 ( ) ().
* 정본: GET /api/admin/login-history?result&userId&page&size PageResponse<LoginHistoryRow> (LoginHistoryController).
* 보안: 이메일/IP ( ·, §0-3).
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { IconSearch } from '../../components/ui/icons';
import { fmtDateTime } from '../work/workShared';
import { loginHistoryApi } from './systemBasicsApi';
import './admin-modules.css';
const RESULTS = ['', 'SUCCESS', 'FAIL', 'LOCKED'];
export function LoginHistoryPage() {
const { t } = useTranslation();
const [result, setResult] = useState('');
const [userId, setUserId] = useState('');
const [applied, setApplied] = useState<{ result: string; userId: string }>({ result: '', userId: '' });
const [page, setPage] = useState(0);
const size = 20;
const q = useQuery({
queryKey: ['admin-login-history', applied, page],
queryFn: () => loginHistoryApi.list(applied.result, applied.userId, page, size),
retry: false,
});
const rows = q.data?.items ?? [];
const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / size));
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('loginHistory.title')}</h1>
<p className="kx-adm__subtitle">{t('loginHistory.subtitle')}</p>
</div>
</header>
<section className="kx-card kx-adm-filter" aria-label={t('loginHistory.filterAria')}>
<div className="kx-adm-filter__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('loginHistory.result')}</span>
<select className="kx-adm-field__select" value={result} onChange={(e) => setResult(e.target.value)}>
{RESULTS.map((r) => (
<option key={r || 'all'} value={r}>
{r ? t(`loginHistory.result_${r}`) : t('loginHistory.allResults')}
</option>
))}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('loginHistory.userId')}</span>
<input className="kx-adm-field__input" value={userId} onChange={(e) => setUserId(e.target.value)}
placeholder={t('loginHistory.userIdPh')} />
</label>
<div className="kx-adm-filter__actions">
<Button leadingIcon={<IconSearch size={16} />}
onClick={() => { setPage(0); setApplied({ result, userId }); }}>
{t('sysadmin.search')}
</Button>
</div>
</div>
</section>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('loginHistory.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card">
{rows.length === 0 ? (
<EmptyState title={t('loginHistory.emptyTitle')} description={t('loginHistory.emptyDesc')} />
) : (
<>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th>{t('loginHistory.colTime')}</th>
<th>{t('loginHistory.colResult')}</th>
<th>{t('loginHistory.colUser')}</th>
<th>{t('loginHistory.colEmail')}</th>
<th>{t('loginHistory.colIp')}</th>
<th>{t('loginHistory.colReason')}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="kx-adm-mono">{fmtDateTime(r.createdAt)}</td>
<td><ResultPill value={r.result} /></td>
<td className="kx-adm-mono">{r.userId ?? '-'}</td>
<td>{r.emailMasked ?? '-'}</td>
<td className="kx-adm-mono">{r.ipMasked ?? '-'}</td>
<td className="kx-adm-muted">{r.failReason ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager page={page} totalPages={totalPages} total={total} onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} />
</>
)}
</section>
)}
</div>
);
}
function ResultPill({ value }: { value: string | null }) {
const { t } = useTranslation();
const v = (value ?? '').toUpperCase();
const tone = v === 'SUCCESS' ? 'success' : v === 'FAIL' || v === 'LOCKED' ? 'error' : 'neutral';
const label = v ? t(`loginHistory.result_${v}`) : '-';
return <span className={`kx-adm-pill kx-adm-pill--${tone}`}>{label}</span>;
}
export function Pager({ page, totalPages, total, onPrev, onNext }: {
page: number; totalPages: number; total: number; onPrev: () => void; onNext: () => void;
}) {
const { t } = useTranslation();
return (
<div className="kx-adm-pager">
<p className="kx-adm-pager__info tnum">{t('sysadmin.totalN', { n: total.toLocaleString() })}</p>
<div className="kx-adm-pager__nav">
<Button variant="ghost" onClick={onPrev} disabled={page === 0}>{t('sysadmin.prev')}</Button>
<span className="kx-adm-pager__page tnum">{page + 1} / {totalPages}</span>
<Button variant="ghost" onClick={onNext} disabled={page >= totalPages - 1}>{t('sysadmin.next')}</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,183 @@
/*
* W12 () .
* 정본: GET/POST /api/admin/programs · DELETE /{id} (ProgramController). / ( ).
*/
import { 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 { Button } from '../../components/ui/Button';
import { IconPlus, IconSearch } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared';
import { programApi, PROGRAM_TYPES, type Program, type ProgramSaveBody } from './systemBasicsApi';
import './admin-modules.css';
export function ProgramAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const [keyword, setKeyword] = useState('');
const [applied, setApplied] = useState('');
const [editing, setEditing] = useState<Program | null | undefined>(undefined);
const q = useQuery({
queryKey: ['admin-programs', applied],
queryFn: () => programApi.list(applied || undefined),
retry: false,
});
const rows = q.data ?? [];
const saveM = useMutation({
mutationFn: (body: ProgramSaveBody) => programApi.save(body),
onSuccess: () => { show(t('program.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-programs'] }); },
onError: (e) => show(errMessage(e)),
});
const delM = useMutation({
mutationFn: (id: string) => programApi.remove(id),
onSuccess: () => { show(t('program.deleted')); qc.invalidateQueries({ queryKey: ['admin-programs'] }); },
onError: (e) => show(errMessage(e)),
});
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('program.title')}</h1>
<p className="kx-adm__subtitle">{t('program.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('program.add')}</Button>
</header>
<section className="kx-card kx-adm-filter">
<div className="kx-adm-filter__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('sysadmin.keyword')}</span>
<input className="kx-adm-field__input" value={keyword} onChange={(e) => setKeyword(e.target.value)}
placeholder={t('program.searchPh')} />
</label>
<div className="kx-adm-filter__actions">
<Button leadingIcon={<IconSearch size={16} />} onClick={() => setApplied(keyword)}>{t('sysadmin.search')}</Button>
</div>
</div>
</section>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('program.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card">
{rows.length === 0 ? (
<EmptyState title={t('program.emptyTitle')} description={t('program.emptyDesc')} />
) : (
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th>{t('program.colName')}</th>
<th>{t('program.colType')}</th>
<th>{t('program.colUrl')}</th>
<th>{t('program.colCategory')}</th>
<th>{t('program.colUse')}</th>
<th aria-label={t('sysadmin.actions')} />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.programName}</td>
<td><span className="kx-adm-pill kx-adm-pill--neutral">{r.programType}</span></td>
<td className="kx-adm-mono">{r.programUrl ?? '-'}</td>
<td>{r.category ?? '-'}</td>
<td>{r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')}</td>
<td className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(r)}>{t('sysadmin.edit')}</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('program.deleteConfirm', { name: r.programName }))) delM.mutate(r.id); }}>
{t('sysadmin.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{editing !== undefined && (
<ProgramModal program={editing} saving={saveM.isPending}
onClose={() => setEditing(undefined)} onSubmit={(b) => saveM.mutate(b)} />
)}
{toast}
</div>
);
}
function ProgramModal({ program, saving, onClose, onSubmit }: {
program: Program | null; saving: boolean; onClose: () => void; onSubmit: (b: ProgramSaveBody) => void;
}) {
const { t } = useTranslation();
const isNew = program === null;
const [programName, setName] = useState(program?.programName ?? '');
const [programType, setType] = useState(program?.programType ?? 'SCREEN');
const [programUrl, setUrl] = useState(program?.programUrl ?? '');
const [category, setCategory] = useState(program?.category ?? '');
const [useYn, setUseYn] = useState(program?.useYn ?? 'Y');
const [error, setError] = useState<string | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!programName.trim()) { setError(t('program.errRequired')); return; }
onSubmit({
id: program?.id ?? null,
programName: programName.trim(),
programType,
programUrl: programUrl.trim() || null,
category: category.trim() || null,
useYn,
});
}
return (
<div className="kx-adm-modal" role="dialog" aria-modal="true">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 className="kx-adm-modal__title">{isNew ? t('program.add') : t('program.edit')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('program.colName')}</span>
<input className="kx-adm-field__input" value={programName} onChange={(e) => setName(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('program.colType')}</span>
<select className="kx-adm-field__select" value={programType} onChange={(e) => setType(e.target.value)}>
{PROGRAM_TYPES.map((x) => <option key={x} value={x}>{x}</option>)}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('program.colCategory')}</span>
<input className="kx-adm-field__input" value={category} onChange={(e) => setCategory(e.target.value)} />
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('program.colUrl')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={programUrl} onChange={(e) => setUrl(e.target.value)}
placeholder="/admin/users" />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('sysadmin.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y">{t('sysadmin.use')}</option>
<option value="N">{t('sysadmin.noUse')}</option>
</select>
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}>{t('sysadmin.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('sysadmin.saving') : t('sysadmin.save')}</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,148 @@
/*
* W12 - ( × read/write ).
* 정본: GET/POST /api/admin/roles/{roleCode}/menus (RoleMenuController). roleApi(RoleController).
* /(rolePerm=RoleAdminPage) .
*/
import { useEffect, 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 { Button } from '../../components/ui/Button';
import { errMessage, useToast } from '../work/workShared';
import { roleApi } from './adminCrudApi';
import { roleMenuApi, type RoleMenu } from './systemBasicsApi';
import './admin-modules.css';
export function RoleMenuAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const rolesQ = useQuery({ queryKey: ['admin-roles'], queryFn: roleApi.list, retry: false });
const [selected, setSelected] = useState<string | null>(null);
const roles = rolesQ.data ?? [];
useEffect(() => {
if (!selected && roles.length > 0) setSelected(roles[0].roleCode);
}, [roles, selected]);
const menusQ = useQuery({
queryKey: ['admin-role-menus', selected],
queryFn: () => roleMenuApi.list(selected!),
enabled: !!selected,
retry: false,
});
const [draft, setDraft] = useState<Record<string, { readYn: string; writeYn: string; mapped: boolean }>>({});
useEffect(() => {
if (menusQ.data) {
const d: Record<string, { readYn: string; writeYn: string; mapped: boolean }> = {};
for (const m of menusQ.data) d[m.menuId] = { readYn: m.readYn, writeYn: m.writeYn, mapped: m.mapped };
setDraft(d);
}
}, [menusQ.data]);
const saveM = useMutation({
mutationFn: () => {
const menus = Object.entries(draft)
.filter(([, v]) => v.mapped)
.map(([menuId, v]) => ({ menuId, readYn: v.readYn, writeYn: v.writeYn }));
return roleMenuApi.save(selected!, { menus });
},
onSuccess: () => { show(t('roleMenu.saved')); qc.invalidateQueries({ queryKey: ['admin-role-menus', selected] }); },
onError: (e) => show(errMessage(e)),
});
function toggleMapped(m: RoleMenu, on: boolean) {
setDraft((d) => ({ ...d, [m.menuId]: { ...d[m.menuId], mapped: on, readYn: on ? (d[m.menuId]?.readYn || 'Y') : 'N' } }));
}
function setFlag(m: RoleMenu, key: 'readYn' | 'writeYn', on: boolean) {
setDraft((d) => ({ ...d, [m.menuId]: { ...d[m.menuId], [key]: on ? 'Y' : 'N' } }));
}
const menus = menusQ.data ?? [];
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('roleMenu.title')}</h1>
<p className="kx-adm__subtitle">{t('roleMenu.subtitle')}</p>
</div>
<Button disabled={!selected || saveM.isPending} onClick={() => saveM.mutate()}>
{saveM.isPending ? t('sysadmin.saving') : t('sysadmin.save')}
</Button>
</header>
{rolesQ.isLoading && <Skeleton height={320} radius={12} />}
{rolesQ.isError && !rolesQ.isLoading && <ErrorState message={t('roleMenu.loadError')} onRetry={() => rolesQ.refetch()} />}
{!rolesQ.isLoading && !rolesQ.isError && (
roles.length === 0 ? (
<EmptyState title={t('roleMenu.noRoles')} />
) : (
<div className="kx-adm-matrix">
<section className="kx-card kx-adm-matrix__roles" aria-label={t('roleMenu.roleList')}>
<div className="kx-adm-codes__head"><h2>{t('roleMenu.role')}</h2></div>
<ul className="kx-adm-glist">
{roles.map((r) => (
<li key={r.roleCode}>
<button type="button" className={`kx-adm-gitem ${r.roleCode === selected ? 'is-active' : ''}`}
onClick={() => setSelected(r.roleCode)}>
<span className="kx-adm-gitem__name">{r.roleName}</span>
<span className="kx-adm-gitem__code">{r.roleCode}</span>
</button>
</li>
))}
</ul>
</section>
<section className="kx-card kx-adm-matrix__perms" aria-label={t('roleMenu.matrix')}>
{menusQ.isLoading && <Skeleton height={280} radius={8} />}
{menusQ.isError && !menusQ.isLoading && <ErrorState message={t('roleMenu.loadError')} onRetry={() => menusQ.refetch()} />}
{!menusQ.isLoading && !menusQ.isError && (
menus.length === 0 ? (
<EmptyState title={t('roleMenu.noMenus')} />
) : (
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th>{t('roleMenu.colMenu')}</th>
<th className="kx-adm-center">{t('roleMenu.colAccess')}</th>
<th className="kx-adm-center">{t('roleMenu.colRead')}</th>
<th className="kx-adm-center">{t('roleMenu.colWrite')}</th>
</tr>
</thead>
<tbody>
{menus.map((m) => {
const st = draft[m.menuId] ?? { readYn: 'N', writeYn: 'N', mapped: false };
return (
<tr key={m.menuId}>
<td style={{ paddingLeft: m.parentId ? 28 : 12 }}>{m.menuName}</td>
<td className="kx-adm-center">
<input type="checkbox" checked={st.mapped} onChange={(e) => toggleMapped(m, e.target.checked)} />
</td>
<td className="kx-adm-center">
<input type="checkbox" checked={st.readYn === 'Y'} disabled={!st.mapped}
onChange={(e) => setFlag(m, 'readYn', e.target.checked)} />
</td>
<td className="kx-adm-center">
<input type="checkbox" checked={st.writeYn === 'Y'} disabled={!st.mapped}
onChange={(e) => setFlag(m, 'writeYn', e.target.checked)} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
)}
</section>
</div>
)
)}
{toast}
</div>
);
}

View File

@ -0,0 +1,101 @@
/*
* W12 .
* 정본: GET /api/admin/system-health SystemHealth (SystemHealthController). DB/Redis////.
* 보안: 내부IP· ( ).
*/
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { systemHealthApi, type SystemHealth } from './systemBasicsApi';
import './admin-modules.css';
export function SystemHealthPage() {
const { t } = useTranslation();
const q = useQuery({
queryKey: ['admin-system-health'],
queryFn: systemHealthApi.get,
retry: false,
refetchInterval: 15000,
});
const h = q.data;
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('sysHealth.title')}</h1>
<p className="kx-adm__subtitle">{t('sysHealth.subtitle')}</p>
</div>
<Button variant="ghost" onClick={() => q.refetch()}>{t('sysHealth.refresh')}</Button>
</header>
{q.isLoading && <Skeleton height={220} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('sysHealth.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && h && (
<>
<section className="kx-adm-health-grid">
<StatusCard label={t('sysHealth.db')} value={h.db} tone={statusTone(h.db)} />
<StatusCard label={t('sysHealth.redis')} value={h.redis} tone={statusTone(h.redis)} />
<StatusCard label={t('sysHealth.worker')} value={h.worker}
sub={t('sysHealth.queueDepth', { n: h.queueDepth < 0 ? 'N/A' : h.queueDepth })}
tone={statusTone(h.worker)} />
</section>
<section className="kx-card kx-adm-metrics">
<Metric label={t('sysHealth.disk')} value={metricPct(h.diskFreePct)}
sub={t('sysHealth.diskFree', { mb: h.diskFreeMb.toLocaleString() })} />
<Metric label={t('sysHealth.mem')} value={metricPct(h.memUsedPct)} />
<Metric label={t('sysHealth.uptime')} value={fmtUptime(h.uptimeSec)} />
<Metric label={t('sysHealth.version')} value={h.version} />
</section>
</>
)}
</div>
);
}
function statusTone(v: string): 'success' | 'error' | 'neutral' {
const s = (v ?? '').toUpperCase();
if (s === 'UP') return 'success';
if (s === 'DOWN') return 'error';
return 'neutral';
}
function metricPct(n: number): string {
return n < 0 ? 'N/A' : `${n}%`;
}
function StatusCard({ label, value, sub, tone }: {
label: string; value: string; sub?: string; tone: 'success' | 'error' | 'neutral';
}) {
return (
<div className={`kx-card kx-adm-health-card kx-adm-health-card--${tone}`}>
<span className="kx-adm-health-card__label">{label}</span>
<span className="kx-adm-health-card__value">{value}</span>
{sub && <span className="kx-adm-health-card__sub">{sub}</span>}
</div>
);
}
function Metric({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="kx-adm-metric">
<span className="kx-adm-metric__label">{label}</span>
<span className="kx-adm-metric__value tnum">{value}</span>
{sub && <span className="kx-adm-metric__sub">{sub}</span>}
</div>
);
}
function fmtUptime(sec: number): string {
if (sec < 0) return 'N/A';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export type { SystemHealth };

View File

@ -601,3 +601,59 @@
.kx-adm-modal__grid { grid-template-columns: 1fr; } .kx-adm-modal__grid { grid-template-columns: 1fr; }
.kx-adm-toolbar { flex-direction: column; align-items: stretch; } .kx-adm-toolbar { flex-direction: column; align-items: stretch; }
} }
/* ═══════════ W12 시스템관리 기본기(조직·프로그램·역할메뉴·인증정책·이력·상태·설정) ═══════════ */
/* 부서 트리 */
.kx-adm-tree { list-style: none; margin: 0; padding: var(--space-2) 0; display: grid; }
.kx-adm-tree__row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; border-bottom: 1px solid var(--color-neutral-100);
}
.kx-adm-tree__row:last-child { border-bottom: none; }
.kx-adm-tree__name { font-size: var(--fs-body); color: var(--color-neutral-900); }
.kx-adm-tree__actions { display: inline-flex; gap: var(--space-2); }
/* 시스템 상태 카드 */
.kx-adm-health-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); margin-bottom: var(--space-3);
}
.kx-adm-health-card {
display: flex; flex-direction: column; gap: 4px; padding: var(--space-4);
border-left: 4px solid var(--color-neutral-200);
}
.kx-adm-health-card--success { border-left-color: var(--color-success); }
.kx-adm-health-card--error { border-left-color: var(--color-error); }
.kx-adm-health-card--neutral { border-left-color: var(--color-warning); }
.kx-adm-health-card__label { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-health-card__value { font-size: var(--fs-h2); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-health-card__sub { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); }
.kx-adm-metric { display: flex; flex-direction: column; gap: 4px; }
.kx-adm-metric__label { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-metric__value { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-metric__sub { font-size: var(--fs-caption); color: var(--color-neutral-500); }
/* 폼(인증정책·설정) */
.kx-adm-form { display: grid; gap: var(--space-3); }
.kx-adm-form__section {
font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900);
margin: var(--space-2) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-neutral-100);
}
.kx-adm-form__section:first-child { border-top: none; padding-top: 0; }
/* 메일/알림 설정 */
.kx-adm-config-list { display: grid; gap: var(--space-3); }
.kx-adm-config-field {
display: grid; grid-template-columns: 1fr 260px; gap: var(--space-3); align-items: center;
padding-bottom: var(--space-2); border-bottom: 1px solid var(--color-neutral-100);
}
.kx-adm-config-field__key { display: flex; flex-direction: column; gap: 2px; }
.kx-adm-config-field__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); }
.kx-adm-center { text-align: center; }
@media (max-width: 1024px) {
.kx-adm-health-grid { grid-template-columns: 1fr; }
.kx-adm-metrics { grid-template-columns: 1fr 1fr; }
.kx-adm-config-field { grid-template-columns: 1fr; }
}

Some files were not shown because too many files have changed in this diff Show More