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:
parent
f6635edb06
commit
5ddd7136a9
23
.claude/agents/kintex-benchmark-analyst.md
Normal file
23
.claude/agents/kintex-benchmark-analyst.md
Normal 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 하네스가 수행.
|
||||||
24
.claude/agents/kintex-crawler-dev.md
Normal file
24
.claude/agents/kintex-crawler-dev.md
Normal 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 체크섬).
|
||||||
30
.claude/agents/kintex-menu-ia-dev.md
Normal file
30
.claude/agents/kintex-menu-ia-dev.md
Normal 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와 소유권 조율(동시 작업 금지 — 메뉴 상수만 관할).
|
||||||
32
.claude/agents/kintex-testdata-dev.md
Normal file
32
.claude/agents/kintex-testdata-dev.md
Normal 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와 협의(임의 신설 최소).
|
||||||
27
.claude/agents/kintex-wise-ui-auditor.md
Normal file
27
.claude/agents/kintex-wise-ui-auditor.md
Normal 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가 소비한다. 정렬 불가/예외 후보는 별도 절로 분리해 오케스트레이터 판단에 올린다.
|
||||||
31
.claude/agents/kintex-wise-ui-dev.md
Normal file
31
.claude/agents/kintex-wise-ui-dev.md
Normal 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에 계약을 요청(직접 백엔드 수정 금지).
|
||||||
35
.claude/skills/kintex-benchmark-orchestrator/SKILL.md
Normal file
35
.claude/skills/kintex-benchmark-orchestrator/SKILL.md
Normal 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신규) → 검증.
|
||||||
|
- 에러: 로그인 필요 페이지 → 공개 범위만 분석·한계 명시.
|
||||||
31
.claude/skills/kintex-menu-recompose/SKILL.md
Normal file
31
.claude/skills/kintex-menu-recompose/SKILL.md
Normal 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` 갱신(편입/이동/제외 근거 포함).
|
||||||
|
|
||||||
|
## 상시 규칙
|
||||||
|
- 새 화면을 만드는 모든 웨이브는 이 스킬 기준으로 메뉴 편입까지가 완료 정의다(누락 = 미완).
|
||||||
|
- 소유자가 카테고리를 바꾸면 위 표를 갱신하고 버전을 올린다(변경 이력 주석).
|
||||||
45
.claude/skills/kintex-renewal-orchestrator/SKILL.md
Normal file
45
.claude/skills/kintex-renewal-orchestrator/SKILL.md
Normal 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 스킵(멱등)·충돌 목록 보고.
|
||||||
49
.claude/skills/kintex-wise-ui-orchestrator/SKILL.md
Normal file
49
.claude/skills/kintex-wise-ui-orchestrator/SKILL.md
Normal 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에 없는 화면 정렬 요청 → 예외 목록 보고 + 셸 수준만 정렬.
|
||||||
@ -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 후속 반영 필요), 시크릿 미기재 |
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
134
docs/analysis/coex-website.md
Normal file
134
docs/analysis/coex-website.md
Normal 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>
|
||||||
109
docs/design.md
109
docs/design.md
@ -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.05–09.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 무수정 |
|
||||||
|
|||||||
@ -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
27
mobile/eas.json
Normal 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
15339
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}"
|
||||||
|
|||||||
@ -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 이면 행사 전체 부스, 있으면 해당 부스코드만.
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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())));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 "***";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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\">본 메일은 수신동의(마케팅 정보 수신)를 하신 분께 발송되었습니다. "
|
||||||
|
+ "수신을 원치 않으시면 마이페이지 > 수신설정에서 수신거부하실 수 있습니다. "
|
||||||
|
+ "(정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)</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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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_fail↔login.maxFailedAttempts, lock_minutes↔login.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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';
|
||||||
|
-- ============================================================================
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "出展案内",
|
||||||
|
|||||||
@ -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": "참가안내",
|
||||||
|
|||||||
@ -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": "参展指南",
|
||||||
|
|||||||
120
src/frontend/src/screens/admin/AuthPolicyPage.tsx
Normal file
120
src/frontend/src/screens/admin/AuthPolicyPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/frontend/src/screens/admin/CompanyAdminPage.tsx
Normal file
232
src/frontend/src/screens/admin/CompanyAdminPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/frontend/src/screens/admin/DeptAdminPage.tsx
Normal file
150
src/frontend/src/screens/admin/DeptAdminPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/frontend/src/screens/admin/ErrorLogPage.tsx
Normal file
98
src/frontend/src/screens/admin/ErrorLogPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/frontend/src/screens/admin/InfraConfigPage.tsx
Normal file
115
src/frontend/src/screens/admin/InfraConfigPage.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
137
src/frontend/src/screens/admin/LoginHistoryPage.tsx
Normal file
137
src/frontend/src/screens/admin/LoginHistoryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/frontend/src/screens/admin/ProgramAdminPage.tsx
Normal file
183
src/frontend/src/screens/admin/ProgramAdminPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/frontend/src/screens/admin/RoleMenuAdminPage.tsx
Normal file
148
src/frontend/src/screens/admin/RoleMenuAdminPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/frontend/src/screens/admin/SystemHealthPage.tsx
Normal file
101
src/frontend/src/screens/admin/SystemHealthPage.tsx
Normal 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 };
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user