feat(ux,domain,data): checkpoint — search domain, approval WISE, calendar M/W/D, holidays 2050, dept org-tree, offcanvas, settings icon, V39 demo realism

- search: domain results (event/exhibition/company/auction/booth/lead/CMS) with routes; PII-safe
- approval WISE: compose + approval-line editor, reject flow, resubmit rejected, 4-tab inbox
- schedule: month/week/day views (WISE CalendarView), 50:50 split, day-number top, no bottom gap
- holidays: system-mgmt holiday admin + V41 KR holidays 2024~2050 (lunar+substitute),
  calendar red + holiday-name label; route/menu wired
- dept: WISE org-tree 2-pane
- shared Offcanvas component; hall-assign chip -> right slide-in detail
- top-bar settings icon; home 2-col bottom-aligned; analytics ?tab= deep-link; grid font
  unified to Nifty "Advanced table headers"; form width cap; 2-pane 50:50
- data V39: 3 ongoing exhibitions + rich closed-loop (e-2026-live) + demo-account event
  membership + demo OTP seed — resolves "everything goes home" / floorplan+gallery 403
- Nifty design-system learning doc + refs index; product name unified; new harnesses
  (wise-ui, renewal/testdata, benchmark/crawler, menu-ia)
- i18n: all pending keys merged, missing=0 (except in-progress 3-track keys)
- verified: tsc -b --force, vite build, compileJava, test all EXIT 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 17:44:30 +09:00
parent 3649870082
commit 433eb83a16
61 changed files with 7227 additions and 403 deletions

View File

@ -0,0 +1,96 @@
# 킨텍스 디자인 시스템 — Nifty × kx 토큰 (학습 문서)
> **목적:** 소유자 확정(2026-07-12) — 킨텍스 공통 UI의 디자인 기준 = **Nifty(themeon, Bootstrap 5 기반 관리자 테마)**. 이 문서는 Nifty 컴포넌트 패턴을 킨텍스 **kx 디자인 토큰**으로 번역하는 단일 규칙서다. 모든 UI 작업(에이전트·수동)은 착수 전 이 문서를 읽고 준수한다.
> 레퍼런스 인덱스: `docs/analysis/nifty-design-refs.md`. 토큰 정본: `src/frontend/src/styles/tokens.css`.
---
## 0. 대원칙 (불변)
1. **kx 토큰으로만 번역** — Nifty/Bootstrap의 hex·px를 그대로 쓰지 말고 아래 토큰에 매핑. 하드코딩 hex 금지.
2. **다크/라이트 양 테마** — 색은 `--color-*`(테마 remap) 사용. `--dk-*` 직접 참조 금지.
3. **이모지 금지** — 아이콘은 선(stroke) SVG(`components/ui/icons.tsx`)만.
4. **WISE 우선** — 셸·시스템관리·공통기능·캘린더는 WISE(UIWS) 구조가 정본. Nifty는 WISE에 없는 **공통 컴포넌트 스타일**(테이블·카드·버튼·드롭다운·리스트그룹·모달·뱃지·알럿·탭)의 레퍼런스.
5. **접근성** — focus-visible 링(`--focus-ring`)·role/aria·WCAG AA·`prefers-reduced-motion`.
## 1. 토큰 매핑표 (Nifty/Bootstrap → kx)
### 색상 (Bootstrap contextual → kx)
| Nifty/BS | 의미 | kx 토큰 |
|---|---|---|
| primary | 주 액션 | `--color-primary-600`(#0066b3) / hover `--color-primary-700`(#004c86) |
| success | 성공·승인 | `--color-success`(#0e8a5f) / bg `--color-success-bg` |
| warning | 경고·임박 | `--color-warning`(#b45309) / bg `--color-warning-bg` |
| danger | 위험·오류 | `--color-error`(#d92d20) / bg `--color-error-bg` |
| info | 보조 정보 | `--color-primary-050/100` + `--color-primary-700` 텍스트 |
| default/light | 중립 | `--color-neutral-100` bg / `--color-neutral-700` 텍스트 / `--border-card` |
| dark | 강조 텍스트 | `--color-neutral-900`(#101828) |
| (AI 전용) | AI 기능 | `--color-ai-accent`(#6d4aff) / surface `--color-ai-surface` |
### 타이포 (Nifty 위계 → kx `--fs-*`)
| 용도 | kx 토큰 |
|---|---|
| 페이지 타이틀(h1) | `--fs-h1` 24/32 |
| 섹션(h2) | `--fs-h2` 20/28 |
| 카드 제목(h3) | `--fs-h3` 16/24 |
| 본문 | `--fs-body` 14/22 |
| 캡션·헤더셀·메타 | `--fs-caption` 12/18 |
| 숫자열 | `--fs-body` + `font-variant-numeric: tabular-nums`(`.tnum`) |
| 코드/모노 | `--fs-mono` 13 / `--font-mono` |
### 간격·모양
| Nifty | kx |
|---|---|
| 컴포넌트 내부 패딩 | `--space-2`(8) ~ `--space-4`(16) |
| 카드 패딩 | `--space-4`(16) ~ `--space-5`(24) |
| 요소 간 gap | `--space-1~3` |
| 섹션 간 | `--space-5`(24, 상한 `--space-6` 32) |
| 버튼·인풋 radius | `--radius-sm`(4) |
| 카드·패널 radius | `--radius-lg`(8) |
| 배지·필·토글 | `--radius-pill` |
## 2. 컴포넌트 규격 (Nifty 패턴 → kx 구현)
### 2.1 버튼 (`Button` / `.kx-btn`)
- **변형**: primary(채움) · secondary(아웃라인) · ghost(투명) · danger. Nifty의 8색 전부를 만들지 말고 **의미 단위**(주/보조/위험/AI)로 수렴.
- **크기**: `sm`(높이 28·`--fs-caption`) · `md`(기본 36·`--fs-body`) · `lg`(44). Nifty xs~lg를 3단으로.
- **상태**: hover(명도 1단계)·active·disabled(opacity .5·cursor not-allowed)·loading(스피너). focus-visible 링 필수.
- **아이콘**: leadingIcon(선 SVG)·icon-only(정사각·aria-label). 버튼 그룹은 인접 radius 접합.
- **블록**: `block`=100% 폭.
### 2.2 카드 (`.kx-card`)
- 구조: `kx-card`(테두리 `--border-card`·radius-lg·bg `--color-white`) > `kx-card__head`(제목 h3·우측 액션) · body · `kx-card__foot`(선택).
- Nifty 변형: **컬러 좌측 액센트 바**(상태 카드), **KPI 타일**(수치 강조 `--fs-display`), **미디어 카드**(포스터). 그림자는 과하지 않게(hover만 약한 elevation).
### 2.3 테이블 (`.kx-table` — Nifty "Advanced table headers")
- thead th: bg `--color-neutral-050`·`--fs-caption`·중간색(`--color-neutral-500`)·좌정렬·`border-bottom: 2px --color-neutral-200`·sticky top.
- tbody td: `--fs-body`·`--color-neutral-700`·패딩 10px 12px·`border-bottom 1px`.
- zebra(`--zebra`)·행 hover(`--color-primary-050`)·선택행(`--color-primary-100`)·숫자열 `.kx-num` 우정렬 tnum.
- 인터랙티브(정렬·필터·페이지·CSV)는 **Tabulator**로 별도(§3).
### 2.4 드롭다운 / 셀렉트
- 트리거(버튼) + 메뉴(카드형·radius-sm·그림자 약)·항목 hover(`--color-primary-050`)·구분선·아이콘·위험 항목(danger 색). 키보드(↑↓·Esc·Enter)·aria-expanded.
### 2.5 리스트 그룹 (`.kx-list-group`)
- 목록형(공지·활동·검색결과): 행 = 아이콘/점 + 제목 + 메타 + 우측 배지/시간. hover·active·구분선. 링크형은 전체 행 클릭.
### 2.6 뱃지·필·알럿·탭·프로그레스·페이지네이션·모달·툴팁·offcanvas
- **뱃지/필**: `--radius-pill`·`--fs-caption`·상태색 bg+text(위 색표).
- **알럿**: 상태색 배경(연)+좌측 액센트+아이콘+닫기. info/success/warn/danger.
- **탭/세그**: `.kx-seg`(현존) — 활성 밑줄 또는 채움. role=tablist.
- **모달/메시지박스**: 백드롭(반투명)+카드(radius-lg)+헤더/바디/푸터(액션 버튼 우측). 확인/경고 메시지박스는 아이콘+제목+본문+2버튼. focus trap·Esc.
- **offcanvas**: 우/좌 슬라이드 패널(모바일 드로어·상세 슬라이드). 백드롭·Esc·트랜지션(reduced-motion 존중).
- **프로그레스/페이지네이션**: 상태색·`--radius-pill`(bar)·현재 페이지 강조.
## 3. Tabulator (인터랙티브 데이터 그리드)
- 도입 시: 테마 CSS를 kx 토큰으로 오버라이드(헤더=§2.3 스타일 정합)·다크 대응·한글 로케일·CSV export. 관리자/대용량 목록에 파일럿 후 확산. 정적 표는 `.kx-table` 유지.
## 4. 적용 절차 (에이전트 지침)
1. 이 문서 + `nifty-design-refs.md` 읽기 → 대상 요소의 Nifty 패턴 파악.
2. 해당 URL을 WebFetch로 확인(구조·변형·상태) → §1 토큰으로 번역.
3. 공통 컴포넌트(`components/ui/*`·`shared.css`)에 표준 확립 → 화면별 산재 스타일 수렴(중복 제거).
4. 검증: `tsc -b --force`·`vite build` EXIT 0 + 다크/라이트 스팟 + 헤드리스 렌더 대조.
## 변경 이력
| 일자 | 내용 |
|---|---|
| 2026-07-12 | 최초 작성 — Nifty(BS5) → kx 토큰 매핑·컴포넌트 규격·적용 절차. 소유자 "Nifty 스타일 학습" 지시 |

View File

@ -1,6 +1,6 @@
# 킨텍스 AI 전시·행사시스템 (KINTEX AI Exhibition & Event System) 기획서 # 킨텍스 AI 전시·행사시스템 (KINTEX AI Exhibition & Event System) 기획서
> 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-12 · 버전: **v3.2** > 작성: 기획 에이전트(planner) · 작성일: 2026-07-11 · 최종 갱신: 2026-07-12 · 버전: **v3.3**
> **제품 공식 명칭(소유자 확정 2026-07-12): "KINTEX AI 전시·행사시스템"** — 기존 "자동전시시스템(Exhibition Automation Platform)"을 대체한다. 전시(exhibition)뿐 아니라 행사·이벤트(event) 전반을 포괄한다는 의미를 명칭에 반영. 본 명칭이 정본이며, 이하 본문·3-트랙 기획·타 문서 후속 반영 시 이 명칭을 사용한다(기존 "자동전시시스템" 표기는 문맥 보존을 위해 삭제하지 않고 잔존하나, 신규 표기는 정본 명칭을 따른다). > **제품 공식 명칭(소유자 확정 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/coex-website.md` (코엑스 VISITOR/BUSINESS/CYBER 3-사이트 IA 분석 + 킨텍스 대비표, 2026-07-12) — §2-2 대외 접점 3-트랙 재구성 확정 근거
@ -10,6 +10,7 @@
> **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은 대외 트랙 외부에 그대로 유지). > **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은 대외 트랙 외부에 그대로 유지).
> **v3.3 로그인 사용자 구분 → 메인페이지·메뉴 그룹 결정 규칙**: 소유자 확정(2026-07-12) — **"로그인한 사용자의 사용자 구분으로 메인페이지가 결정되고, 좌측 메뉴 그룹도 결정된다."** §2-2의 3-트랙(visitor/business/agency) + 내부 역할(ops/admin) 위에, **미인증 기본 진입 = visitor 공개 관람 랜딩**, **인증 후 역할별 전용 메인**(business 메인·agency 메인·visitor 메인은 신규 화면 — designer Stitch 의뢰, admin/내부 = 관리 메인 또는 비즈니스 메인+시스템관리)과 **트랙별 좌측 메뉴 그룹 노출/숨김**을 결정하는 규칙을 **§2-3**에 확정한다. 기존 §2·§2-2·화면(SCR-*)·라우트는 보존(안 B 정합), 결정 규칙·트랙 메인 화면 목록만 순증. src·design.md 미수정(frontend·designer 후속).
> **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다. > **문서 소유권**: 본 PLANNING.md는 planner만 수정한다. design.md·타 문서는 이 문서 변경 시 "designer/planner 후속 반영 필요"로만 표기하고 직접 수정하지 않는다.
--- ---
@ -257,6 +258,110 @@
--- ---
## 2-3. 사용자 구분 → 랜딩(메인페이지)·메뉴/탭 그룹 결정 규칙 — 웹+모바일 공통 (v3.3, 소유자 확정 2026-07-12)
> **소유자 확정 규칙**: **"로그인한 사용자의 사용자 구분(역할)으로 메인페이지가 결정되고, 좌측 메뉴 그룹(모바일은 하단 탭바)도 결정된다."** 두 가지 축을 확정한다 — ① **미인증(비로그인) 기본 진입 = visitor 트랙**(공개 관람 랜딩), ② **인증 후에는 역할이 매핑되는 트랙의 전용 메인**으로 랜딩하고 메뉴/탭 그룹을 트랙-스코프로 노출한다. **웹과 모바일 앱은 동일 로직**을 따른다(모바일은 셸 대신 하단 탭바 구성이 역할별로 결정).
> **보존·경계**: §2 6역할/7페르소나·§2-2 3-트랙·§2-1 포털 매트릭스·기존 화면(SCR-*)·기존 딥 라우트(안 B, §2-2-4)를 **삭제·재작성하지 않는다.** 본 절은 그 위에 **랜딩 결정 로직 + 트랙별 메뉴/탭 가시성 + 신규 트랙 메인 화면 목록**만 순증한다. 본 절은 §2-2-3(C)의 "트랙-스코프 홈" 권고를 소유자 확정으로 **정밀화**한다 — 기존 홈(SCR-02/05/27)을 그대로 재사용하는 대신, **각 트랙 전용 "메인" 신규 화면(designer Stitch 의뢰)이 기존 역할 위젯을 조합**하는 형태로 확정.
> **현행 기준(실측)**: 현재 로그인 후 전원 `/home`(SCR-HOME) 고정(`LoginPage.completeLogin → navigate('/home')`), AppShell `GROUPS`(ops·design·visitor·finance·work·system) 전원 동일 노출(system 그룹만 `isAdmin` 게이트). 역할 신호: `EventRole = ORGANIZER|EXHIBITOR|CONTRACTOR|HALL_MANAGER`(워크스페이스 `myRole`) + `globalRole`(`app_user.role_code` = ADMIN|MANAGER|VISITOR…). authStore: `globalRole()`·`roleForCurrent()`·`workspaces`. **src·design.md 미수정(frontend·kintex-mobile·designer 후속).**
### 2-3-1. 역할 → 트랙 매핑 확정표 (웹·모바일 공통)
| 트랙 | 구성 역할(§2) | 판정 신호 | 웹 셸 | 모바일 앱 타깃(§2-1-1) |
|---|---|---|---|---|
| **visitor**(관람) | 관람객·일반대중·게스트 | **미인증** OR `globalRole=VISITOR` OR 업무 워크스페이스 0개 | 공개/관람객 셸(MDI 비적용, §2.7-6) | **관람객 앱(B2C)** |
| **business**(비즈니스) | ORGANIZER·EXHIBITOR | `myRole ∈ {ORGANIZER, EXHIBITOR}` 워크스페이스 보유 | AppShell(MDI) | **운영 앱(B2B)** |
| **agency**(에이전시) | CONTRACTOR·등록/협력업체 | `myRole=CONTRACTOR` 워크스페이스 보유(상위 트랙 없음) | AppShell(MDI) | **운영 앱(B2B)** |
| **내부-ops** | HALL_MANAGER·킨텍스 운영 | `globalRole=MANAGER` OR `myRole=HALL_MANAGER` 워크스페이스 | AppShell(MDI) | **운영 앱(B2B)** |
| **내부-admin** | 테넌트/플랫폼 관리자 | `globalRole=ADMIN`(=현행 `isAdmin`) | AppShell(MDI) + 시스템관리 그룹 | 웹 전용(모바일 백오피스 미제공, §2-1-1) |
- **트랙 판정 헬퍼**: `trackOf(EventRole)` → ORGANIZER/EXHIBITOR = business, CONTRACTOR = agency, HALL_MANAGER = 내부-ops. `globalRole` 오버레이 → ADMIN = 내부-admin(system 그룹 부여), MANAGER = 내부-ops, VISITOR/null = visitor(업무 워크스페이스 없을 때).
### 2-3-2. 판정 우선순위 · 멀티역할 · 트랙 스위칭
- **primaryTrack 우선순위(높음→낮음)**: **내부-admin > 내부-ops > business > agency > visitor.** 복수 역할 사용자(계정 통합, §2-1-1)의 **로그인 랜딩**은 이 우선순위로 결정한다. 예: `admin+organizer` → 내부-admin 랜딩(관리 메인, 단 비즈니스 메뉴도 접근), `contractor+exhibitor` → business 랜딩(둘 다 업무 트랙, business 우위).
- **activeTrack(반응형)**: 좌측 상단 **행사 전환 드롭다운(기존)** 으로 워크스페이스를 바꾸면 `activeTrack = trackOf(roleForCurrent())` 로 따라 바뀌어 메뉴/탭이 재스코프된다 → **별도 트랙 스위처 UI 신설 최소화**(기존 워크스페이스 선택 재사용). `globalRole=ADMIN` 의 시스템관리 오버레이는 activeTrack과 무관하게 항상 유지(현행 `isAdmin` 게이트 보존).
- **연속성**: 트랙별 메인/메뉴가 달라도 계정은 단일 통합(§2-1-1)이므로 리드·비즈매칭·재방문 이력은 훼손되지 않는다(관람객→바이어/참가 승격 시 business 트랙 접근 권한만 추가).
### 2-3-3. 랜딩(메인페이지) 결정표
**(A) 웹** — `completeLogin` 분기 + 미인증 루트 가드.
| 순서 | 상태·조건(신호) | 트랙 | 메인페이지 | 라우트(권고·안 B) | 화면 |
|---|---|---|---|---|---|
| 0 | **미인증(토큰 없음)** | visitor | 공개 관람 랜딩 | `/` (현행 `/login` 리다이렉트 → visitor 랜딩으로) | **SCR-T0(신규)** |
| 1 | `globalRole=ADMIN` | 내부-admin | 관리 메인(**또는** 비즈니스 메인+시스템관리) | `/admin`(또는 `/home`) | SCR-16(+SCR-T2) |
| 2 | `globalRole=MANAGER` OR HALL_MANAGER ws | 내부-ops | 운영 메인(비즈니스 메인 운영 변형) | `/home` | SCR-T2(운영 변형) |
| 3 | ORGANIZER/EXHIBITOR ws | business | 비즈니스 메인 | `/home` | **SCR-T2(신규)** |
| 4 | CONTRACTOR ws(1~3 없음) | agency | 에이전시 메인(수주·옥션) | `/contractor/dashboard`(또는 `/agency`) | **SCR-T3(신규)** |
| 5 | `globalRole=VISITOR` OR 업무 ws 0 | visitor | 관람객 메인 | `/visitor` | **SCR-T1(신규)** |
| — | 판정 불가(폴백) | — | 안전 폴백 | `/home` | SCR-HOME(현행) |
**(B) 모바일 앱** — 동일 로직(진입 시 미인증=관람객, 로그인 후 역할별 홈 탭 네비게이터 스위칭).
| 순서 | 상태·조건 | 트랙/역할 | 모바일 진입(랜딩) | 화면 |
|---|---|---|---|---|
| 0 | **미인증(토큰 없음)** | visitor(게스트) | **관람객 진입** — 행사탐색·사전등록·티켓·게스트 예매 | 모바일 관람객 홈(신규) |
| 1 | ADMIN | (모바일 백오피스 없음) | 보유 업무역할 메인, 없으면 관람 최소 뷰 | — |
| 2 | MANAGER/HALL_MANAGER | 내부-ops | 운영 홈(승인·현장 상황판) | SCR-M2 계열 + 운영 홈(신규) |
| 3 | ORGANIZER/EXHIBITOR | business | 업무 홈(주최=대시보드 / 참가=내 부스) | 모바일 비즈니스 홈(신규) |
| 4 | CONTRACTOR | agency | 수주/옥션 홈 + 현장 | SCR-M1 계열 + 모바일 에이전시 홈(신규) |
| 5 | VISITOR/게스트 | visitor | 관람객 홈(티켓·배지·wayfinding·매칭) | SCR-M5~M9·M14·M15 계열 |
### 2-3-4. 좌측 메뉴 그룹 매트릭스 (웹 AppShell `GROUPS`)
노출 규칙: `●` 전체 노출 · `◐` 부분(항목 필터) · `✕` 숨김. 현행 `visibleGroups = GROUPS.filter(!g.admin || isAdmin)` 를 **트랙 필터 추가**로 확장.
| `GROUPS.id` (그룹명) | 내부-admin | 내부-ops | business(주최) | business(참가) | agency | visitor |
|---|---|---|---|---|---|---|
| `ops` 전시 운영 | ● | ● | ● | ◐(대시보드·참가업체 조회) | ✕ | ✕ |
| `design` 설계·시공 | ● | ●(검수·홀 부하) | ● | ●(부스설계·유틸·시각화) | ●(수주부스·공사옥션·규정·반입) | ✕ |
| `visitor` 관람객·마케팅 | ● | ● | ● | ◐(리드) | ✕ | ✕(셸 미노출) |
| `finance` 정산·분석 | ● | ● | ● | ◐(내 정산) | ✕ | ✕ |
| `work` 업무 공통 | ● | ● | ● | ● | ● | ✕ |
| `system` 시스템관리 | ● | ✕ | ✕ | ✕ | ✕ | ✕ |
- **visitor 트랙**은 업무 AppShell(GROUPS) 미노출 — §2-2-3(B)의 공개/관람객 셸(내 티켓·내 행사·방문가이드·알림 최소 메뉴)을 사용한다.
- **agency `design` 그룹 부분(◐→항목)**: 노출 = `수주 부스`(`/contractor/dashboard`)·`공사 옥션`(`/auctions`)·`서류·마일스톤`(도면제출)·`반입·반출`. 숨김 = 주최자 전용 `플로어플랜 스튜디오` 편집.
- **내부-ops(홀매니저)**: `design`은 검수·홀 부하 관점(플로어플랜 RO), `system` 제외(테넌트 관리자 아님).
### 2-3-5. 하단 탭바 매트릭스 (모바일, 역할별 결정)
design.md §2-2 모바일 하단 탭 4개(홈/내 부스·승인/알림/더보기)를 **역할별 구성으로 확정**(탭 4~5개).
| 트랙/역할 | 탭1 (홈) | 탭2 | 탭3 | 탭4 | 탭5 |
|---|---|---|---|---|---|
| visitor(미인증·관람객) | 행사탐색 | 티켓·배지 | 길찾기(wayfinding) | 알림 | 더보기(게스트→가입) |
| business 주최자 | 대시보드 | 참가·승인 | 현장 상황판 | 알림 | 더보기 |
| business 참가업체 | 내 부스 | 체크인·리드스캔 | 시각화 | 알림 | 더보기 |
| agency 공사·협력 | 수주·옥션 | 현장 체크리스트 | 통행증 | 알림 | 더보기 |
| 내부-ops 홀매니저 | 홈 | 승인·검수 | 현장(안전·혼잡) | 알림 | 더보기 |
| 내부-admin | (모바일 백오피스 미제공 — 웹 전용) | | | | |
### 2-3-6. 신규 트랙 메인 화면 목록·스펙 (designer Stitch 의뢰 대상)
> planner는 **화면 목록·용도·핵심 섹션·재사용 위젯**까지 확정한다. **SCR 번호 확정·Stitch 영어 프롬프트 작성·생성·이식은 designer 소관**(MEMORY: 전 화면 Stitch 경유). 아래 가칭 SCR-T0~T3(웹)·모바일 3종은 designer가 design.md에서 정식 번호·프롬프트로 확정한다.
| # | 화면(가칭) | 채널 | 트랙 | 핵심 섹션/위젯 | 셸 | 조합할 기존 위젯 |
|---|---|---|---|---|---|---|
| 1 | **SCR-T0 공개 관람 랜딩(루트)** | 웹 | visitor(미인증) | 히어로·행사 카드·사전등록/티켓 CTA·**business/agency 로그인 진입**·방문가이드 허브 | 공개 셸 | §2-2-3(B) visitor 서브홈, SCR-P1 |
| 2 | **SCR-T1 관람객 메인** | 웹 | visitor(인증) | 내 티켓·모바일 배지·행사탐색·wayfinding·비즈매칭 | 관람객 셸 | SCR-P4·P7·P8 |
| 3 | **SCR-T2 비즈니스 메인** | 웹 | business/내부-ops | KPI·D-데이 할일·내 행사 — **역할 변형**(주최=마일스톤·홀 SCR-02 / 참가=내 부스·마감 SCR-05) | AppShell | SCR-02·05·HOME |
| 4 | **SCR-T3 에이전시 메인** | 웹 | agency | 수주 목록·진행 중 옥션·응찰 현황·규정검증 요약·작업신고 | AppShell | SCR-27·28·09·38 |
| 5 | **모바일 관람객 홈** | 모바일 | visitor | 행사탐색·티켓·배지·wayfinding | 관람객 앱 | SCR-M5~M9 |
| 6 | **모바일 비즈니스 홈** | 모바일 | business | 대시보드/내 부스·승인·리드(역할 변형) | 운영 앱 | (신규) |
| 7 | **모바일 에이전시 홈** | 모바일 | agency | 수주·옥션·현장 | 운영 앱 | SCR-M1 |
- **관리 메인**(내부-admin)은 **기존 SCR-16 관리 대시보드 재사용**(신규 아님) — 비즈니스 메인(SCR-T2)+시스템관리 그룹 조합 대안 병기.
- **신규 Stitch 화면 = 웹 4(SCR-T0~T3) + 모바일 3 = 7종.** 나머지 세부 업무 화면은 전량 기존 SCR 재사용(안 B).
### 2-3-7. 구현 권고 및 담당 (지시서 포인터)
- **웹 (frontend 담당)**: ① `LoginPage.completeLogin``resolveLandingTrack(user, workspaces)``landingPath(track)` 분기로 교체(현행 `/home` 고정 폐기, business/내부는 `/home` 유지 = 회귀 0, agency=`/contractor/dashboard`·visitor=`/visitor` 신설 분기). ② 미인증 루트 `/` = SCR-T0(현행 `/login` 강제 리다이렉트 완화). ③ `AppShell.visibleGroups`**트랙 필터** 추가(`GROUPS`/item에 선택적 `tracks?` 허용목록 필드, `activeTrack` 파생). ④ authStore에 `activeTrack()` 셀렉터 신설. **라우트·화면 재작성 없음(안 B).**
- **모바일 (kintex-mobile 담당)**: ① 앱 진입 미인증 = 관람객 탭 네비게이터. ② 로그인 후 `resolveLandingTrack` 동일 규칙으로 역할별 탭 네비게이터 스위칭(§2-3-5). ③ 탭 구성은 트랙-스코프. WISE 모바일 레퍼런스 컨벤션 준수.
- **지시서**: `_workspace/plan_role_routing.md`(웹+모바일 섹션·담당·웨이브 명시). **design.md/designer 후속**: 신규 트랙 메인 7종 Stitch 의뢰 + §2.7-6 역할별 적용·§2-1 사이트맵·§4 모바일 탭 매트릭스에 본 규칙 반영.
---
## 3. 현행(As-Is) 프로세스 분석 ## 3. 현행(As-Is) 프로세스 분석
### 3-1. 주최자 타임라인과 병목 ### 3-1. 주최자 타임라인과 병목
@ -1091,5 +1196,6 @@ 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.3** | 2026-07-12 | planner | **사용자 구분 → 랜딩(메인페이지)·메뉴/탭 그룹 결정 규칙 확정 — 웹+모바일 공통(소유자 확정 2026-07-12).** 기존 스코프(§2 6역할·§2-2 3-트랙·§2-1 포털·화면 SCR-*·딥 라우트) 전부 보존, 결정 로직·트랙 메인 목록만 순증(안 B). ①헤더 v3.3 규칙 요약 라인 + 버전 상향 ②**§2-3 신설** — (2-3-1) 역할→트랙 매핑 확정표(visitor/business/agency/내부-ops/내부-admin, 판정 신호=EventRole `myRole`+globalRole `role_code`, 웹 셸·모바일 앱 타깃) (2-3-2) primaryTrack 우선순위(내부-admin>내부-ops>business>agency>visitor)·activeTrack 반응형(기존 행사 전환 재사용)·멀티역할 연속성 (2-3-3) 랜딩 결정표 **(A)웹**(미인증=SCR-T0 공개 관람 랜딩, 인증=역할별 전용 메인, completeLogin 분기·현행 `/home` 고정 교체) **(B)모바일**(동일 로직·역할별 홈 탭 스위칭) (2-3-4) 웹 AppShell `GROUPS` 트랙별 노출 매트릭스(●/◐/✕, `visibleGroups` 트랙 필터 확장) (2-3-5) **모바일 하단 탭바 역할별 매트릭스** (2-3-6) **신규 트랙 메인 화면 목록·스펙 7종**(웹 SCR-T0 공개랜딩·T1 관람객·T2 비즈니스(역할변형)·T3 에이전시 + 모바일 관람객/비즈니스/에이전시 홈 — designer Stitch 의뢰 대상, 관리 메인=SCR-16 재사용) (2-3-7) 구현 권고·담당(웹=frontend·모바일=kintex-mobile, 지시서 `_workspace/plan_role_routing.md`). §2-2-3(C) "트랙-스코프 홈" 권고를 소유자 확정으로 정밀화(기존 홈 재사용→전용 신규 메인이 기존 위젯 조합). **src·design.md 미수정** — frontend/kintex-mobile/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.2** | 2026-07-12 | planner | **제품 명칭 확정 + 대외 접점 3-트랙 재구성(소유자 확정 2026-07-12).** 기존 스코프(M1~M18·§1A 멀티테넌시·§2 6역할/7페르소나·§2-1 포털 매트릭스·§5B 공통레이어·§6 나노바나나·§8 아키텍처·design.md SCR-*) **전부 보존**, IA 레이어만 순증. ①**제품 공식 명칭 = "KINTEX AI 전시·행사시스템"**(기존 "자동전시시스템/Exhibition Automation Platform" 대체 — 전시+행사/이벤트 포괄) → H1 제목·헤더 명칭 절 갱신, 정본 선언(기존 표기는 문맥 보존상 잔존) ②**`docs/analysis/coex-website.md` 신설** — 코엑스 3-사이트 IA(VISITOR `coex.co.kr`/BUSINESS `business.coex.co.kr`/CYBER 참가신청 `cybercoex.co.kr`) 분석 + 킨텍스(kintex.com) 대비표 11축 + 차용 요소. WebFetch 3사이트 + WebSearch 보조 ③**§2-2 대외 접점 3-트랙 재구성 신설** — visitor(관람객)/business(주최자·참가업체)/agency(공사·장치·협력사) 3-트랙 정의(대상·여정·진입화면·기능 매핑), 코엑스 차용요소, 공개사이트 구조(관문 랜딩 3-분기 + 트랙별 서브홈 3 + **로그인 후 트랙-스코프 홈 권고**), **라우트 체계 3안 비교→하이브리드(트랙 셸 + 기존 딥라우트 유지, 마이그레이션 비용 최소) 권고**, 기존 SCR/라우트→트랙 재배치 매핑표(신규 화면 4종=관문+서브홈3만, 나머지 재사용). 트랙은 역할의 상위 묶음(대체 아님), 내부역할 ops·admin은 트랙 외부 유지. 헤더 v3.2 정체성 라인 추가. **design.md·src 미수정(designer/frontend 후속)** — 구현 지시서 `_workspace/plan_3track.md` 별도 작성 |
| **v3.1** | 2026-07-11 | planner | **계정 통합 + 가입 트랙 분리 · 모바일 2타깃 확정(소유자 확정 2026-07-11).** 기존 스코프 전부 보존, 계정·앱 채널 전략만 정밀화(상충 시 확정안 우선·삭제 없이 개정 표기). ①**§2-1 채널 매트릭스 개정** — 모바일 열을 운영 앱(B2B)/관람객 앱(B2C)으로 분리, 가입 트랙 열 신설(업무=승인·초대+2FA 필수 vs 관람객=간편가입/게스트 예매+2FA 미강제) ②**§2-1-1 신설** — (A)계정 체계 단일 통합+가입 트랙 분리(승격 시 단일 계정 등급 상향으로 리드·비즈매칭·재방문 이력 연속성 유지), (B)모바일 코드베이스 1개(Expo `mobile/`)·배포 타깃 2개(운영 앱=스토어 미공개 사내 QR/APK, 관람객 앱=스토어 공개), 관람객 1차 접점=공개 웹(SCR-P7/P8)·앱=리텐션 채널 ③**§5B-3 인증 정밀화** — 2FA 대상을 "업무 사용자 필수·관람객 미강제"로, 승격 시 2FA 필수 전환 명시 ④**M10 갱신** — 간편가입/게스트 예매(SCR-P7)·계정 승격 연속성·게스트 예매 개인정보/병합 정책 반영. 근거: `docs/analysis/ticketing-app-benchmark.md`(게스트 예매·간편가입 업계 표준). design.md·타 문서 미수정(designer 후속 반영 필요), 시크릿 미기재 | | **v3.1** | 2026-07-11 | planner | **계정 통합 + 가입 트랙 분리 · 모바일 2타깃 확정(소유자 확정 2026-07-11).** 기존 스코프 전부 보존, 계정·앱 채널 전략만 정밀화(상충 시 확정안 우선·삭제 없이 개정 표기). ①**§2-1 채널 매트릭스 개정** — 모바일 열을 운영 앱(B2B)/관람객 앱(B2C)으로 분리, 가입 트랙 열 신설(업무=승인·초대+2FA 필수 vs 관람객=간편가입/게스트 예매+2FA 미강제) ②**§2-1-1 신설** — (A)계정 체계 단일 통합+가입 트랙 분리(승격 시 단일 계정 등급 상향으로 리드·비즈매칭·재방문 이력 연속성 유지), (B)모바일 코드베이스 1개(Expo `mobile/`)·배포 타깃 2개(운영 앱=스토어 미공개 사내 QR/APK, 관람객 앱=스토어 공개), 관람객 1차 접점=공개 웹(SCR-P7/P8)·앱=리텐션 채널 ③**§5B-3 인증 정밀화** — 2FA 대상을 "업무 사용자 필수·관람객 미강제"로, 승격 시 2FA 필수 전환 명시 ④**M10 갱신** — 간편가입/게스트 예매(SCR-P7)·계정 승격 연속성·게스트 예매 개인정보/병합 정책 반영. 근거: `docs/analysis/ticketing-app-benchmark.md`(게스트 예매·간편가입 업계 표준). design.md·타 문서 미수정(designer 후속 반영 필요), 시크릿 미기재 |

View File

@ -0,0 +1,30 @@
# Nifty 디자인 레퍼런스 (소유자 지정, 2026-07-12)
> **방향 확정(2026-07-12):** 킨텍스 공통 UI의 **디자인 기준을 Nifty(themeon)로 채택**한다. 개별 요소를 하나씩 지정받는 중 — 아래 표에 계속 누적. 적용은 wise-ui 하네스가 요소별 웨이브로 kx 토큰 번역 통일.
킨텍스 UI 정렬의 스타일 레퍼런스. GUARDiA ITSM이 원래 Nifty 다크 테마 기반이라 프로젝트 계보와 정합.
적용은 kx 토큰으로 번역(하드코딩 금지), 다크/라이트 양 테마, 이모지 금지(선 SVG).
| 영역 | 레퍼런스 URL | 적용 대상 | 상태 |
|---|---|---|---|
| 정적 테이블 헤더 | https://preview.themeon.net/nifty/tables/static-tables/ ("Advanced table headers") | `.kx-table` 공통 표준 | 진행 중 |
| 인터랙티브 그리드 | https://preview.themeon.net/nifty/tables/tabulator/ | 공통 DataGrid(정렬·필터·페이지·CSV export) — 관리자/목록 | 대기 |
| 상세 페이지 | https://preview.themeon.net/nifty/blog-apps/blog/ | 행사·전시·공지·옥션 등 상세 뷰 | 대기 |
| 카드 | https://preview.themeon.net/nifty/ui-elements/cards/ | `.kx-card` 공통 카드(헤더·바디·푸터·액션·컬러바) | 대기 |
| 버튼 | https://preview.themeon.net/nifty/ui-elements/buttons/ | `Button`/`.kx-btn` 공통(변형·크기·상태·아이콘·그룹) | 대기 |
| 드롭다운 | https://preview.themeon.net/nifty/ui-elements/dropdowns/ | 드롭다운/메뉴/셀렉트(kx 드롭다운) | 대기 |
| 컴포넌트 전반 | https://preview.themeon.net/nifty/ui-elements/components/ | 뱃지·알럿·탭·모달·툴팁·프로그레스·페이지네이션 등 공통 컴포넌트 | 대기 |
| 리스트 그룹 | https://preview.themeon.net/nifty/ui-elements/list-group/ | 리스트 그룹(공지·활동·검색결과·목록형 카드) | 대기 |
| 타이포그래피 | https://preview.themeon.net/nifty/ui-elements/ (typography) | 제목·본문·캡션 위계(kx 타이포 토큰) | 대기 |
| 모달/메시지박스 | https://preview.themeon.net/nifty/ui-elements/ (modals) | 모달·확인/경고 메시지박스(kx Modal) | 대기 |
| Offcanvas | https://preview.themeon.net/nifty/ui-elements/ (offcanvas) | 오프캔버스 패널(모바일 드로어·상세 슬라이드) | 대기 |
| 달력 | https://preview.themeon.net/nifty/app-views/calendar/ | 일정 캘린더(월/주/일)·이벤트 색/드래그·사이드 목록 | 대기(일정 3-뷰는 구조 완료, Nifty 스타일 정합 대상) |
| **UI-elements 전체** | **https://preview.themeon.net/nifty/ui-elements/** | **Nifty UI-elements 전 페이지를 킨텍스 공통 UI 기준으로 채택**(위 개별 + 나머지 요소 전부) | 기준 |
| 셸·메뉴(기존) | WISE(UIWS) AppLayout/LeftNav/CalendarView | 셸·아코디언·캘린더 | 완료(구조) |
| 상단바(top frame) | https://preview.themeon.net/nifty/ (navbar/header) | 상단 헤더 — 로고·검색·알림·설정·사용자 드롭다운·풀스크린·언어 | 대기 |
| 설정(레이아웃 커스터마이저) | https://preview.themeon.net/nifty/ (설정 아이콘 → 우측 패널) | 상단 설정 아이콘 클릭 → 우측 offcanvas: 테마(라이트/다크)·레이아웃 모드·사이드바·색상 등 실시간 조정 | 대기(공용 Offcanvas 재사용) |
## 원칙
- 레퍼런스의 **구조·위계·인터랙션**을 차용하되, 색·간격·폰트는 kx 디자인 토큰으로 번역.
- WISE(UIWS)가 대응물을 가진 영역은 WISE 우선(셸·시스템관리·공통기능). Nifty는 WISE에 없는 표현(고급 테이블·blog형 상세)의 레퍼런스.
- 신규 npm 의존성(Tabulator 등)은 번들·다크·i18n 정합 확인 후 파일럿→확산.

View File

@ -0,0 +1,87 @@
package com.zioinfo.kintex.system.holiday;
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 시스템관리(관리자 CRUD) + 캘린더 공개(인증) 조회.
* <ul>
* <li>관리: {@code /api/admin/holidays} 목록/추가/삭제/휴무토글, ADMIN 게이트({@link SystemAccessGuard}).</li>
* <li>공개(인증): {@code GET /api/holidays?from=&to=} 기간 실제 휴무만(붉은색 표시용).</li>
* </ul>
* 데이터·시드(2024~2050) V41 소유 API는 조회·임시공휴일 관리만. 테넌트 스코프.
*/
@RestController
public class HolidayController {
private final HolidayService service;
private final SystemAccessGuard guard;
public HolidayController(HolidayService service, SystemAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** 관리자 — 목록(year 또는 from/to, 휴무 off 포함). */
@GetMapping("/api/admin/holidays")
public ApiResponse<List<HolidayDto>> adminList(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(value = "year", required = false) Integer year,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.adminList(principal.tenantId(), year, from, to));
}
/** 관리자 — 데이터 존재 연도 목록(년 선택 드롭다운). */
@GetMapping("/api/admin/holidays/years")
public ApiResponse<List<Integer>> years(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.years(principal.tenantId()));
}
/** 관리자 — 임시공휴일 추가(PK 충돌 시 409). */
@Audited(action = "HOLIDAY_CREATE", targetType = "holiday")
@PostMapping("/api/admin/holidays")
public ApiResponse<HolidayDto> create(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody HolidayCreateRequest req) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.create(principal.tenantId(), req));
}
/** 관리자 — 삭제. */
@Audited(action = "HOLIDAY_DELETE", targetType = "holiday")
@DeleteMapping("/api/admin/holidays/{ymd}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String ymd) {
guard.requireAdmin(principal);
service.delete(principal.tenantId(), ymd);
return ApiResponse.ok(null);
}
/** 관리자 — 휴무 여부 토글(임시휴무 on/off). */
@Audited(action = "HOLIDAY_TOGGLE", targetType = "holiday")
@PatchMapping("/api/admin/holidays/{ymd}")
public ApiResponse<Void> toggle(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String ymd,
@Valid @RequestBody HolidayToggleRequest req) {
guard.requireAdmin(principal);
service.toggle(principal.tenantId(), ymd, req.isHoliday());
return ApiResponse.ok(null);
}
/** 공개(인증) — 캘린더 붉은색 표시용. 기간 내 실제 휴무만. */
@GetMapping("/api/holidays")
public ApiResponse<List<HolidayDto>> calendar(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to) {
guard.require(principal);
return ApiResponse.ok(service.publicRange(principal.tenantId(), from, to));
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.kintex.system.holiday;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* 임시공휴일 추가 요청 관리자 전용. PK(tenant_id, ymd) 충돌 409.
* {@code ymd} yyyy-MM-dd. {@code isSubstitute} 선택(기본 false).
*/
public record HolidayCreateRequest(
@NotBlank @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "날짜는 yyyy-MM-dd 형식이어야 합니다.")
String ymd,
@NotBlank @Size(max = 60) String name,
Boolean isSubstitute) {
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.kintex.system.holiday;
/**
* 공휴일 DTO 관리 화면·캘린더 붉은색 표시 공용(민감정보 없음).
* <ul>
* <li>{@code ymd} 날짜(yyyy-MM-dd).</li>
* <li>{@code name} 공휴일명(: 신정, 설날, 대체공휴일(추석)).</li>
* <li>{@code isSubstitute} 대체공휴일 여부(배지 표시용).</li>
* <li>{@code isHoliday} 휴무 여부(임시휴무 토글, 관리 화면 전용). 공개 조회는 true만 반환.</li>
* </ul>
* 데이터·시드는 V41__holidays_2050.sql(2024~2050) 소유. DTO는 조회·관리 표현만.
*/
public record HolidayDto(
String ymd,
String name,
boolean isSubstitute,
boolean isHoliday) {
}

View File

@ -0,0 +1,60 @@
package com.zioinfo.kintex.system.holiday;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
/**
* 공휴일 매퍼 V41 holiday 테이블(PK tenant_id, ymd) 조회·관리.
* 정렬 권위 = ymd ASC. ymd는 to_char로 yyyy-MM-dd 문자열 반환(프론트 매칭 ).
* MyBatis 어노테이션 규약: 별칭 AS "x" 쌍따옴표, {@code <script>}/`<` 미사용(MapperAnnotationParseTest).
*/
@Mapper
public interface HolidayMapper {
String COLS = "to_char(ymd, 'YYYY-MM-DD') AS ymd, name, "
+ "is_substitute AS \"isSubstitute\", is_holiday AS \"isHoliday\"";
/** 관리자 — 기간 내 전체(휴무 off 포함), 날짜 오름차순. */
@Select("SELECT " + COLS + " FROM holiday "
+ "WHERE tenant_id = #{tenantId} AND ymd BETWEEN CAST(#{fromYmd} AS date) AND CAST(#{toYmd} AS date) "
+ "ORDER BY ymd")
List<Map<String, Object>> findRange(@Param("tenantId") String tenantId,
@Param("fromYmd") String fromYmd,
@Param("toYmd") String toYmd);
/** 공개(인증) — 기간 내 실제 휴무(is_holiday = true)만, 날짜 오름차순. 캘린더 붉은색 표시용. */
@Select("SELECT " + COLS + " FROM holiday "
+ "WHERE tenant_id = #{tenantId} AND is_holiday = true "
+ "AND ymd BETWEEN CAST(#{fromYmd} AS date) AND CAST(#{toYmd} AS date) "
+ "ORDER BY ymd")
List<Map<String, Object>> findActiveRange(@Param("tenantId") String tenantId,
@Param("fromYmd") String fromYmd,
@Param("toYmd") String toYmd);
/** 존재 확인(중복 추가 방지). */
@Select("SELECT COUNT(1) FROM holiday WHERE tenant_id = #{tenantId} AND ymd = CAST(#{ymd} AS date)")
int exists(@Param("tenantId") String tenantId, @Param("ymd") String ymd);
/** 임시공휴일 추가(등록 즉시 휴무). PK 충돌은 서비스에서 사전 차단(409). */
@Insert("INSERT INTO holiday (tenant_id, ymd, name, is_substitute, is_holiday) "
+ "VALUES (#{tenantId}, CAST(#{ymd} AS date), #{name}, COALESCE(#{isSubstitute}, false), true)")
int insert(@Param("tenantId") String tenantId, @Param("ymd") String ymd,
@Param("name") String name, @Param("isSubstitute") Boolean isSubstitute);
/** 삭제. */
@Delete("DELETE FROM holiday WHERE tenant_id = #{tenantId} AND ymd = CAST(#{ymd} AS date)")
int delete(@Param("tenantId") String tenantId, @Param("ymd") String ymd);
/** 휴무 여부 토글(임시휴무 on/off). */
@Update("UPDATE holiday SET is_holiday = #{isHoliday} "
+ "WHERE tenant_id = #{tenantId} AND ymd = CAST(#{ymd} AS date)")
int updateHoliday(@Param("tenantId") String tenantId, @Param("ymd") String ymd,
@Param("isHoliday") boolean isHoliday);
/** 연도 목록(년 선택 드롭다운) — 데이터 존재 연도 내림차순. */
@Select("SELECT DISTINCT EXTRACT(YEAR FROM ymd)::int AS y FROM holiday "
+ "WHERE tenant_id = #{tenantId} ORDER BY y DESC")
List<Integer> findYears(@Param("tenantId") String tenantId);
}

View File

@ -0,0 +1,123 @@
package com.zioinfo.kintex.system.holiday;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Service;
import java.time.Year;
import java.util.List;
import java.util.Map;
/**
* 공휴일 서비스 V41 holiday(2024~2050) 조회·임시공휴일 관리.
* <ul>
* <li>관리자 목록: year 또는 from/to 조회( 없으면 올해 전체).</li>
* <li>공개(인증) 조회: 캘린더 붉은색 표시용 실제 휴무만.</li>
* <li>추가: PK(tenant_id, ymd) 사전 존재확인 충돌 409.</li>
* </ul>
* 테넌트는 {@code KintexPrincipal.tenantId()} 스코프(멀티테넌시). V41 시드/스키마는 불변.
*/
@Service
public class HolidayService {
/** 임시 조회 범위 상한(2050까지 시드 존재). 방어적 클램프. */
private static final int MIN_YEAR = 2000;
private static final int MAX_YEAR = 2100;
private final HolidayMapper mapper;
public HolidayService(HolidayMapper mapper) {
this.mapper = mapper;
}
/** 관리자 목록 — year 우선, 없으면 from/to, 둘 다 없으면 올해. 휴무 off 포함. */
public List<HolidayDto> adminList(String tenantId, Integer year, String from, String to) {
String[] range = resolveRange(year, from, to);
return mapper.findRange(tenantId, range[0], range[1]).stream().map(HolidayService::toDto).toList();
}
/** 공개(인증) 조회 — 기간 내 실제 휴무만(캘린더 붉은색). from/to 없으면 올해. */
public List<HolidayDto> publicRange(String tenantId, String from, String to) {
String[] range = resolveRange(null, from, to);
return mapper.findActiveRange(tenantId, range[0], range[1]).stream().map(HolidayService::toDto).toList();
}
/** 데이터가 존재하는 연도 목록(년 선택). */
public List<Integer> years(String tenantId) {
return mapper.findYears(tenantId);
}
/** 임시공휴일 추가 — 중복 시 409. */
public HolidayDto create(String tenantId, HolidayCreateRequest req) {
String ymd = req.ymd();
if (mapper.exists(tenantId, ymd) > 0) {
throw new ApiException(ErrorCode.CONFLICT, "이미 등록된 날짜입니다: " + ymd);
}
mapper.insert(tenantId, ymd, req.name().trim(),
req.isSubstitute() != null && req.isSubstitute());
return new HolidayDto(ymd, req.name().trim(),
req.isSubstitute() != null && req.isSubstitute(), true);
}
/** 삭제 — 없으면 404. */
public void delete(String tenantId, String ymd) {
validateYmd(ymd);
if (mapper.delete(tenantId, ymd) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "공휴일을 찾을 수 없습니다: " + ymd);
}
}
/** 휴무 여부 토글(임시휴무 on/off) — 없으면 404. */
public void toggle(String tenantId, String ymd, boolean isHoliday) {
validateYmd(ymd);
if (mapper.updateHoliday(tenantId, ymd, isHoliday) == 0) {
throw new ApiException(ErrorCode.NOT_FOUND, "공휴일을 찾을 수 없습니다: " + ymd);
}
}
// 내부 헬퍼
/** year 또는 from/to → [fromYmd, toYmd]. year 우선, from/to 부분지정 허용, 둘 다 없으면 올해. */
private static String[] resolveRange(Integer year, String from, String to) {
if (year != null) {
int y = clampYear(year);
return new String[] {y + "-01-01", y + "-12-31"};
}
if (from != null && to != null) {
validateYmd(from);
validateYmd(to);
return new String[] {from, to};
}
int y = Year.now().getValue();
String start = from != null ? valid(from) : y + "-01-01";
String end = to != null ? valid(to) : y + "-12-31";
return new String[] {start, end};
}
private static int clampYear(int y) {
return Math.max(MIN_YEAR, Math.min(MAX_YEAR, y));
}
private static String valid(String ymd) {
validateYmd(ymd);
return ymd;
}
private static void validateYmd(String ymd) {
if (ymd == null || !ymd.matches("\\d{4}-\\d{2}-\\d{2}")) {
throw new ApiException(ErrorCode.VALIDATION, "날짜는 yyyy-MM-dd 형식이어야 합니다.");
}
}
private static HolidayDto toDto(Map<String, Object> r) {
return new HolidayDto(
str(r.get("ymd")),
str(r.get("name")),
Boolean.TRUE.equals(r.get("isSubstitute")),
Boolean.TRUE.equals(r.get("isHoliday")));
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

View File

@ -0,0 +1,8 @@
package com.zioinfo.kintex.system.holiday;
import jakarta.validation.constraints.NotNull;
/** 공휴일 휴무 여부 토글 요청 — 임시휴무 on/off(관리자). */
public record HolidayToggleRequest(
@NotNull Boolean isHoliday) {
}

View File

@ -8,8 +8,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 통합검색 매퍼 공통 업무 테이블을 title 기준으로 검색(UNION). DataScope: * 통합검색 매퍼 공통 업무 테이블 + 도메인(행사·업체·옥션·부스·리드·CMS) UNION 검색.
* seeAll false worklog/report/opinion 본인 + 비밀 아님만. * DataScope: seeAll false worklog/report/opinion 본인 + 비밀 아님만.
* 도메인: event tenant_id='KINTEX' 스코프, lead PII 원문(name/phone/email) 미노출(company·product ).
*/ */
@Mapper @Mapper
public interface SearchMapper { public interface SearchMapper {
@ -39,6 +40,31 @@ public interface SearchMapper {
FROM opinion FROM opinion
WHERE title ILIKE '%'||#{q}||'%' WHERE title ILIKE '%'||#{q}||'%'
AND (secret_yn = 'N' <if test="seeAll == false">OR author_id = #{userId}</if>) AND (secret_yn = 'N' <if test="seeAll == false">OR author_id = #{userId}</if>)
UNION ALL
SELECT 'EVENT', id, name,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM event WHERE tenant_id = 'KINTEX' AND name ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'COMPANY', id, name,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM company WHERE name ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'AUCTION', id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM auction WHERE title ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'BOOTH', id, COALESCE(booth_no, booth_id),
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM booth_sale WHERE tenant_id = 'KINTEX' AND booth_no ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'LEAD', id,
COALESCE(NULLIF(concat_ws(' · ', company, product), ''), '리드'),
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM lead WHERE company ILIKE '%'||#{q}||'%' OR product ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'CMS', id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM cms_content WHERE title ILIKE '%'||#{q}||'%'
) s ORDER BY ord DESC LIMIT #{size} ) s ORDER BY ord DESC LIMIT #{size}
</script> </script>
""") """)

View File

@ -1,5 +1,9 @@
package com.zioinfo.kintex.work.search; package com.zioinfo.kintex.work.search;
/** 통합검색 결과 항목. type: WORKLOG|NOTICE|MEETING|REPORT|OPINION. */ /**
public record SearchResultItem(String type, String id, String title, String createdAt) { * 통합검색 결과 항목.
* type: 업무(WORKLOG|NOTICE|MEETING|REPORT|OPINION) + 도메인(EVENT|COMPANY|AUCTION|BOOTH|LEAD|CMS).
* route: type( id) 파생한 프론트 이동 경로(민감정보 미포함).
*/
public record SearchResultItem(String type, String id, String title, String createdAt, String route) {
} }

View File

@ -26,8 +26,32 @@ public class SearchService {
int size = limit <= 0 ? 30 : Math.min(limit, 100); int size = limit <= 0 ? 30 : Math.min(limit, 100);
boolean seeAll = scope.canSeeAll(principal); boolean seeAll = scope.canSeeAll(principal);
List<Map<String, Object>> rows = mapper.searchAll(q.trim(), principal.userId(), seeAll, size); List<Map<String, Object>> rows = mapper.searchAll(q.trim(), principal.userId(), seeAll, size);
return rows.stream().map(r -> new SearchResultItem( return rows.stream().map(r -> {
str(r.get("type")), str(r.get("id")), str(r.get("title")), str(r.get("createdAt")))).toList(); String type = str(r.get("type"));
String id = str(r.get("id"));
return new SearchResultItem(type, id, str(r.get("title")), str(r.get("createdAt")), routeFor(type, id));
}).toList();
}
/** type(+id)별 프론트 이동 경로 파생. 도메인 크로스검색 결과의 클릭 내비게이션용. */
private static String routeFor(String type, String id) {
if (type == null) {
return null;
}
return switch (type) {
case "WORKLOG" -> "/work/worklog";
case "NOTICE" -> "/work/notice";
case "MEETING" -> "/work/meeting";
case "REPORT" -> "/work/report";
case "OPINION" -> "/work/opinion";
case "EVENT" -> "/schedule";
case "COMPANY" -> "/admin/companies";
case "AUCTION" -> id == null ? "/auctions" : "/auctions/" + id;
case "BOOTH" -> "/booth-sales";
case "LEAD" -> "/leads";
case "CMS" -> "/cms";
default -> null;
};
} }
private static String str(Object o) { return o == null ? null : String.valueOf(o); } private static String str(Object o) { return o == null ? null : String.valueOf(o); }

View File

@ -0,0 +1,203 @@
-- ============================================================================
-- V39 데모 데이터 현실화 — "진행 중" 전시 + 서류/마일스톤/CMS/공지 + 데모 계정 워크스페이스
-- 담당: kintex-testdata-dev · 작성: 2026-07-12
-- ----------------------------------------------------------------------------
-- 배경(소유자 지적):
-- • "진행 중"(now() ∈ start~end) 전시가 없음 → 홈 진행중 탭·캘린더·ongoingEvents KPI 공백.
-- • 서류·마일스톤·CMS·공지가 비어 보임(공개 공지는 cms_content published 필요, 데모 계정은
-- e-2026-smf가 '예정'이라 진입해도 실감 데이터 부족).
-- • ★데모 계정(주최자/참가/공사)이 로그인 후 대시보드·플로어플랜·참가업체에서 "다 홈으로 이동"
-- (=행사 미참여). 근본원인 2가지: (1)OTP 필수역할이 OTP_ENROLL로 로그인 차단(→§99 OTP 편의),
-- (2)연결 행사 e-2026-smf가 '예정'뿐이라 진행중 워크스페이스 부재(→§2 진행중 리치행사 연결).
--
-- 원칙(불변):
-- • V1~V38 파일 불변. 본 파일은 순증(additive) — 전부 순수 INSERT ... ON CONFLICT DO NOTHING.
-- 예외: §99 OTP 편의만 '백필형' UPDATE(otp_secret IS NULL 가드 — 기존 값 보존, 멱등).
-- • 테넌트 표준 = 'KINTEX'(대문자). 날짜는 CURRENT_DATE/now() 상대값(배포 시점 기준 항상 유효).
-- • PII 전부 가짜 더미(@kintex.test · 010-0000-xxxx). 실존 인물 금지.
-- • V38 데모 폐루프와 ID 충돌 없음(신규 접두어 e-2026-live / *-live-* / cms-v39-* / nt-live-*).
--
-- 실컬럼 근거(grep V1~V38): event(V2+V23 category/hall_label +V26 tenant_id +V32 poster_url),
-- hall_assignment/layout/booth(V2/V3), booth_sale(V27), required_document/event_milestone(V14),
-- cms_content(V19), notice/notification(V8/V38), app_user.otp_secret varchar(255)/otp_enabled(V12),
-- event_member UNIQUE(event_id,user_id,role_code)(V2). 홈 진행중 탭=HomeMapper.findShowcase(tenant 스코프).
-- ============================================================================
-- ============================================================================
-- 1. 진행 중 전시 3건 — now() ∈ [start,end]. status=active·category='전시'·hall_label·poster_url.
-- HomeMapper.findShowcase('ongoing')·findSummaryCounts.ongoingEvents 는 테넌트 스코프(멤버십 무관)
-- → 아래 3건이 홈 '진행중' 탭·캘린더·KPI를 즉시 채운다. 날짜는 CURRENT_DATE 상대(항상 진행중).
-- ============================================================================
INSERT INTO event (id, name, start_date, end_date, status, tenant_id, category, hall_label, poster_url) VALUES
('e-2026-live', '2026 스마트 물류·자동화 코리아 (LIVE)',
CURRENT_DATE - 1, CURRENT_DATE + 3, 'active', 'KINTEX', '전시', '제1전시장 홀1',
'https://www.kintex.com/imageView.do?atchmnflNo=469205&fileseq=1'),
('e-2026-live2', '2026 국제 첨단로봇 & AI 엑스포',
CURRENT_DATE, CURRENT_DATE + 2, 'active', 'KINTEX', '전시', '제2전시장 홀7 , 전시홀 8',
'https://www.kintex.com/imageView.do?atchmnflNo=469254&fileseq=2'),
('e-2026-live3', '2026 미래 모빌리티 위크',
CURRENT_DATE - 2, CURRENT_DATE + 1, 'active', 'KINTEX', '전시', '제1전시장 홀3 , 전시홀 4',
'https://www.kintex.com/imageView.do?atchmnflNo=469283&fileseq=1')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 2. e-2026-live 리치 폐루프 — 데모 계정이 진입할 '진행중' 워크스페이스.
-- 홀배정(대표)+배치+부스 8(POLYGON)+판매 오버레이+서류+마일스톤+설계안 → 대시보드/플로어플랜/참가업체 채움.
-- SRID 0 = 홀 로컬 평면(미터). 부스=6m×3m(18㎡) 4×2 그리드. 홀=H1(제1전시장 홀1).
-- ============================================================================
-- 2-1) 홀 배정(대표) + 배치 ------------------------------------------------------
INSERT INTO hall_assignment (id, event_id, hall_id, is_primary) VALUES
('ha-live-h1', 'e-2026-live', 'H1', true)
ON CONFLICT (event_id, hall_id) DO NOTHING;
INSERT INTO layout (id, event_id, hall_id, version, name, status) VALUES
('ly-live', 'e-2026-live', 'H1', 1, 'LIVE 데모 배치(진행중)', 'confirmed')
ON CONFLICT (id) DO NOTHING;
-- 2-2) 부스 8(POLYGON) ---------------------------------------------------------
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-live-01','ly-live','L-101','island', ST_GeomFromText('POLYGON((0 0,6 0,6 3,0 3,0 0))',0), 6,3,4.0,1.0,'(주)스마트오토메이션',true),
('booth-live-02','ly-live','L-102','independent', ST_GeomFromText('POLYGON((7 0,13 0,13 3,7 3,7 0))',0), 6,3,3.5,1.0,'델타로보틱스',false),
('booth-live-03','ly-live','L-103','assembled', ST_GeomFromText('POLYGON((14 0,20 0,20 3,14 3,14 0))',0), 6,3,3.0,1.0,'글로벌테크',false),
('booth-live-04','ly-live','L-104','corner', ST_GeomFromText('POLYGON((21 0,27 0,27 3,21 3,21 0))',0), 6,3,3.5,1.0,'미래제조연구소',false),
('booth-live-05','ly-live','L-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-live-06','ly-live','L-106','assembled', ST_GeomFromText('POLYGON((7 4,13 4,13 7,7 7,7 4))',0), 6,3,3.0,1.0,'한국센서테크',false),
('booth-live-07','ly-live','L-107','independent', ST_GeomFromText('POLYGON((14 4,20 4,20 7,14 7,14 4))',0), 6,3,3.5,1.0,NULL,false),
('booth-live-08','ly-live','L-108','assembled', ST_GeomFromText('POLYGON((21 4,27 4,27 7,21 7,21 4))',0), 6,3,3.0,1.0,'바이오헬스랩',false)
ON CONFLICT (id) DO NOTHING;
-- 2-3) 판매 오버레이(booth_sale) — sold 5 · available 2 · held 1 ------------------
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-live-01','e-2026-live','H1','booth-live-01','L-101','sold', 6534000,18,NULL,NULL,'(주)스마트오토메이션','demo-organizer',now()-interval '18 day',NULL,'system-seed'),
('KINTEX','bs-live-02','e-2026-live','H1','booth-live-02','L-102','sold', 5445000,18,NULL,NULL,'델타로보틱스','demo-organizer',now()-interval '14 day',NULL,'system-seed'),
('KINTEX','bs-live-03','e-2026-live','H1','booth-live-03','L-103','sold', 4356000,18,NULL,NULL,'글로벌테크','demo-organizer',now()-interval '10 day',NULL,'system-seed'),
('KINTEX','bs-live-04','e-2026-live','H1','booth-live-04','L-104','sold', 4791600,18,NULL,NULL,'미래제조연구소','demo-organizer',now()-interval '7 day',NULL,'system-seed'),
('KINTEX','bs-live-05','e-2026-live','H1','booth-live-05','L-105','available', NULL, 18,NULL,NULL,NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-live-06','e-2026-live','H1','booth-live-06','L-106','sold', 4356000,18,NULL,NULL,'한국센서테크','demo-organizer',now()-interval '4 day',NULL,'system-seed'),
('KINTEX','bs-live-07','e-2026-live','H1','booth-live-07','L-107','held', NULL, 18,'demo-exhibitor',now()+interval '45 minute',NULL,NULL,NULL,NULL,'system-seed'),
('KINTEX','bs-live-08','e-2026-live','H1','booth-live-08','L-108','available', NULL, 18,NULL,NULL,NULL,NULL,NULL,NULL,'system-seed')
ON CONFLICT (id) DO NOTHING;
-- 2-4) 설계안(design_plan) — 대시보드 designApproved/designTotal 채움 -----------------
INSERT INTO design_plan (id, booth_id, version, status, spec) VALUES
('dp-live-01','booth-live-01',1,'approved', '{"theme":"island-premium","height":4.0}'::jsonb),
('dp-live-02','booth-live-02',1,'approved', '{"theme":"independent","height":3.5}'::jsonb),
('dp-live-03','booth-live-03',1,'review', '{"theme":"assembled-basic","height":3.0}'::jsonb),
('dp-live-06','booth-live-06',1,'draft', '{"theme":"assembled-basic","height":3.0}'::jsonb)
ON CONFLICT (booth_id, version) DO NOTHING;
-- 2-5) 필수 서류(6) — 상태 분포 + 마감 임박(D+2). /docs (진행중 행사) 채움 -------------------
INSERT INTO required_document (id, event_id, doc_type, name, status, due_date, sort_order) VALUES
('doc-live-op', 'e-2026-live','operation_plan','행사운영계획서', 'approved', NULL, 0),
('doc-live-layout', 'e-2026-live','booth_layout', '부스배치도', 'approved', NULL, 1),
('doc-live-disaster','e-2026-live','disaster_plan', '재해대처계획서', 'submitted', CURRENT_DATE + 5, 2),
('doc-live-rigging', 'e-2026-live','rigging_calc', '리깅 구조계산서', 'draft', CURRENT_DATE + 2, 3),
('doc-live-fire', 'e-2026-live','fire_safety', '방화관리 책임서약서', 'submitted', CURRENT_DATE + 4, 4),
('doc-live-elec', 'e-2026-live','elec_safety', '전기안전 점검확인서', 'pending', CURRENT_DATE + 2, 5)
ON CONFLICT (event_id, doc_type) DO NOTHING;
-- 2-6) 마일스톤(5) — 진행중 행사 타임라인(상대일). 개장 완료·현재는 운영 단계 --------------------
INSERT INTO event_milestone (id, event_id, milestone_type, label, due_date, state, sort_order) VALUES
('ms-live-assign', 'e-2026-live','assignment','D-150 배정', CURRENT_DATE - 150, 'done', 0),
('ms-live-prereview','e-2026-live','pre_review','D-30 사전협의', CURRENT_DATE - 30, 'done', 1),
('ms-live-utility', 'e-2026-live','utility', 'D-25 유틸리티', CURRENT_DATE - 25, 'done', 2),
('ms-live-docs', 'e-2026-live','documents', 'D-7 신고서류', CURRENT_DATE - 7, 'done', 3),
('ms-live-opening', 'e-2026-live','opening', 'D-0 개장·운영', CURRENT_DATE - 1, 'active', 4)
ON CONFLICT (event_id, milestone_type) DO NOTHING;
INSERT INTO document_review_issue (id, event_id, tone, title, description, sort_order) VALUES
('dri-live-1','e-2026-live','info','현장 운영중','진행중 행사 — 실시간 서류 상태를 확인하세요.', 0),
('dri-live-2','e-2026-live','warn','마감 임박','리깅 구조계산서·전기안전 점검확인서 마감 D-2.', 1)
ON CONFLICT (id) DO NOTHING;
-- 2-7) ★데모 계정 → e-2026-live(진행중) 멤버십. 로그인 후 '진행중' 워크스페이스 보유
-- → 대시보드/플로어플랜/참가업체가 실제 진행중 행사로 진입("다 홈으로" 해소).
-- findEventRoles ORDER BY start_date → e-2026-live(오늘 개장)가 e-2026-smf(8월)보다 먼저 노출.
INSERT INTO event_member (id, event_id, user_id, role_code, booth_id, company_id) VALUES
('em-live-org', 'e-2026-live', 'demo-organizer', 'ORGANIZER', NULL, NULL),
('em-live-exh', 'e-2026-live', 'demo-exhibitor', 'EXHIBITOR', 'booth-live-02', NULL),
('em-live-con', 'e-2026-live', 'demo-contractor', 'CONTRACTOR', NULL, 'co-0001')
ON CONFLICT (event_id, user_id, role_code) DO NOTHING;
-- ============================================================================
-- 3. CMS 콘텐츠 — NOTICE 6 + PAGE 2 (전부 published). 공개 공지(status='published'
-- AND content_type='NOTICE')·/cms 목록을 채운다. V19 'cms-notice'는 draft라 공개 노출 안 됨.
-- ============================================================================
INSERT INTO cms_content (id, event_id, content_type, title, body, status, lang, signage, mailing, author_name, published_at) VALUES
('cms-v39-nt1','e-2026-live','NOTICE','[개막] 2026 스마트 물류·자동화 코리아 개막 안내','오늘 개막했습니다. 관람 시간 10:00~17:00, 무료 사전등록 관람객 우선 입장.', 'published','ko', true, false, '주최 사무국', now()-interval '1 day'),
('cms-v39-nt2','e-2026-live','NOTICE','현장 등록 데스크 운영 안내','제1전시장 홀1 정문 등록데스크에서 QR 배지를 발급받으세요.', 'published','ko', true, false, '주최 사무국', now()-interval '20 hour'),
('cms-v39-nt3',NULL, 'NOTICE','[공통] 킨텍스 주차 안내','제1·제2전시장 주차장 이용 안내 및 대중교통 노선 정보.', 'published','ko', false, false, '관리자', now()-interval '3 day'),
('cms-v39-nt4','e-2026-live','NOTICE','VIP 바이어 상담회 신청 안내','현장 B2B 상담 라운지에서 1:1 바이어 매칭 상담을 신청하세요.', 'published','ko', false, true, '주최 사무국', now()-interval '2 day'),
('cms-v39-nt5',NULL, 'NOTICE','[안전] 전시장 내 안전수칙','장치물 최고 높이 5m 준수, 비상구·소화기 위치를 확인하세요.', 'published','ko', true, false, '안전관리팀', now()-interval '4 day'),
('cms-v39-nt6','e-2026-live2','NOTICE','국제 첨단로봇 & AI 엑스포 세션 일정','키노트·기술세션 타임테이블을 공식 홈페이지에서 확인하세요.', 'published','ko', false, false, '주최 사무국', now()-interval '12 hour'),
('cms-v39-pg1','e-2026-live','PAGE','행사 소개 — 스마트 물류·자동화 코리아','물류 자동화·AMR·WMS·스마트팩토리 전 분야 300개사 참가. B2B 상담·컨퍼런스 동시 개최.', 'published','ko', false, false, '주최 사무국', now()-interval '5 day'),
('cms-v39-pg2',NULL, 'PAGE','관람 안내 & FAQ','입장·주차·편의시설·와이파이·유아동반 안내 등 자주 묻는 질문 모음.', 'published','ko', false, false, '관리자', now()-interval '6 day')
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 4. 공지(notice §5B) 6 — 진행중 행사(e-2026-live) + 공통(event_id NULL) 혼합.
-- /work/notice·홈 공지 위젯이 어떤 행사 컨텍스트에서도 비지 않도록 전역 공지 포함.
-- ============================================================================
INSERT INTO notice (id, event_id, category, title, content, pinned, author_id, author_name, view_count, published_at) VALUES
('nt-live-01','e-2026-live','EVENT', '[개막] 스마트 물류·자동화 코리아 오늘 개막','오늘 10:00 개막. 진행중 행사 워크스페이스에서 실시간 현황을 확인하세요.', true, 'demo-organizer','데모 주최자(홍길동)', 421, now()-interval '1 day'),
('nt-live-02','e-2026-live','GENERAL','참가업체 부스 운영 수칙','부스 운영시간·전기 사용·통로 확보 수칙을 준수해 주세요.', true, 'demo-organizer','데모 주최자(홍길동)', 245, now()-interval '20 hour'),
('nt-live-03','e-2026-live','EVENT', 'VIP 네트워킹 데이 안내','행사 2일차 오후 네트워킹 라운지를 운영합니다.', false, 'demo-organizer','데모 주최자(홍길동)', 132, now()-interval '15 hour'),
('nt-live-04',NULL, 'SYSTEM', '[공통] 관람객 앱 QR 체크인 안내','모바일 앱에서 QR 배지로 간편 체크인하세요.', false, 'demo-organizer','데모 주최자(홍길동)', 188, now()-interval '2 day'),
('nt-live-05',NULL, 'GENERAL','[공통] 전시장 편의시설 안내','수유실·의무실·물품보관소·푸드코트 위치 안내.', false, 'demo-organizer','데모 주최자(홍길동)', 96, now()-interval '3 day'),
('nt-live-06',NULL, 'SYSTEM', '[시스템] 무선 인터넷(WiFi) 이용 안내','전시장 공용 WiFi SSID 및 접속 방법 안내.', false, 'demo-organizer','데모 주최자(홍길동)', 74, now()-interval '5 day')
ON CONFLICT (id) DO NOTHING;
-- 홈 알림(notification) — 데모 계정에 진행중 행사 컨텍스트 알림(홈 알림센터 채움) ----------------
INSERT INTO notification (id, recipient_id, event_id, noti_type, title, message, link, read_at) VALUES
('no-live-01','demo-organizer', 'e-2026-live','SYSTEM', '진행중 행사 개막','스마트 물류·자동화 코리아가 개막했습니다. 대시보드에서 현황을 확인하세요.', '/events/e-2026-live/dashboard', NULL),
('no-live-02','demo-exhibitor', 'e-2026-live','MESSAGE','부스 배정 확정','L-102 독립부스가 배정되었습니다. 부스 홈에서 설계·유틸리티를 확인하세요.', '/booth-sales', NULL),
('no-live-03','demo-contractor','e-2026-live','SYSTEM', '진행중 행사 참여','스마트 물류·자동화 코리아 공사 파트너로 참여 중입니다.', '/contractor/dashboard', NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================================
-- 99. ★데모 계정 OTP 로그인 편의 — 최소위험안(옵션 a).
-- ----------------------------------------------------------------------------
-- 문제: demo-organizer/exhibitor/contractor 는 event_member 보유 → requiresOtp()=true.
-- V38에서 otp_enabled=false 라 로그인 시 OTP_ENROLL(등록강제) 챌린지로 진입 차단됨.
-- 대안 평가:
-- (a) [채택] 데모 3계정을 'OTP 등록완료' 상태로 시드(고정·공개 base32 시크릿 + otp_enabled=true).
-- → 로그인 시 OTP_REQUIRED(챌린지) → 문서화된 시크릿을 Authenticator/oathtool에 1회 등록하면
-- 이후 재배포에도 동일 코드 생성 가능(랜덤 재등록 불요). 순수 SQL·백엔드 무수정.
-- (b) 운영자가 admin 으로 대리 확인 — 데모 자율 로그인 불가(반려).
-- (c) 백엔드 데모도메인 OTP 예외 — 로직 수정 필요(범위 밖·별도 트랙).
-- 시크릿 노출 위험 평가: 데모 계정은 이미 '공개 자격'(@kintex.test·문서화된 비번 Kintex!Demo<Role>1)
-- 이며 e-2026-smf/e-2026-live 데모 스코프로 한정 → 고정 시크릿 추가의 한계 위험은 미미.
-- ★운영 이관 시 반드시 제거(제거 목록 등재). 실사용자에는 절대 적용 금지.
-- SecretCipher.open(): 접두어 'enc:v1:' 없는 값은 평문으로 그대로 반환(하위호환) → 아래 평문 base32를
-- 그대로 저장하면 TotpService.verify(base32, code)가 정상 검증(env 암호화키 불요).
-- 멱등/비파괴: otp_secret IS NULL 인 데모 계정에만 백필(기존 등록 시크릿 보존, 재실행 무해).
-- ----------------------------------------------------------------------------
UPDATE app_user SET otp_secret = 'ORGANIZERKINTEXDEMOTOTPSECRET234', otp_enabled = true, verify_method = 'OTP'
WHERE id = 'demo-organizer' AND otp_secret IS NULL;
UPDATE app_user SET otp_secret = 'EXHIBITORKINTEXDEMOTOTPSECRET234', otp_enabled = true, verify_method = 'OTP'
WHERE id = 'demo-exhibitor' AND otp_secret IS NULL;
UPDATE app_user SET otp_secret = 'CONTRACTORKINTEXDEMOTOTPSECRET23', otp_enabled = true, verify_method = 'OTP'
WHERE id = 'demo-contractor' AND otp_secret IS NULL;
-- ============================================================================
-- 검증 쿼리(수동 실행용 — 마이그레이션 미포함 주석):
-- -- 진행중 전시: SELECT id,name,start_date,end_date FROM event
-- -- WHERE tenant_id='KINTEX' AND start_date<=CURRENT_DATE AND end_date>=CURRENT_DATE; -- >=3
-- -- 데모 워크스페이스(진행중 우선): SELECT em.event_id,em.role_code,e.start_date FROM event_member em
-- -- JOIN event e ON e.id=em.event_id WHERE em.user_id='demo-organizer' ORDER BY e.start_date; -- e-2026-live 선두
-- -- e-2026-live 대시보드 소스: SELECT count(*) FROM booth b JOIN layout l ON l.id=b.layout_id
-- -- WHERE l.event_id='e-2026-live'; -- 8
-- -- 공개 공지(published NOTICE): SELECT count(*) FROM cms_content WHERE status='published' AND content_type='NOTICE'; -- >=6
-- -- CMS published PAGE: SELECT count(*) FROM cms_content WHERE status='published' AND content_type='PAGE'; -- >=2(+V19)
-- -- 데모 OTP 등록완료: SELECT id,otp_enabled,(otp_secret IS NOT NULL) FROM app_user
-- -- WHERE id IN('demo-organizer','demo-exhibitor','demo-contractor'); -- otp_enabled=t, secret 존재
-- ============================================================================

View File

@ -0,0 +1,472 @@
-- V41: 시스템관리 공휴일 관리 — holiday 테이블 + 한국 법정공휴일 2024~2050 시드(멱등).
-- 음력(설·추석·부처님오신날) 변환 + 대체공휴일(2021 개정 근사) 반영. 달력에서 붉은색 표시 연계.
-- 테넌트 표준: PK(tenant_id, ymd). V1~V40 불변.
CREATE TABLE IF NOT EXISTS holiday (
tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
ymd date NOT NULL,
name varchar(60) NOT NULL,
is_substitute boolean NOT NULL DEFAULT false, -- 대체공휴일 여부
is_holiday boolean NOT NULL DEFAULT true, -- 휴무 여부(임시공휴일 관리용 토글)
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, ymd)
);
CREATE INDEX IF NOT EXISTS idx_holiday_ymd ON holiday (tenant_id, ymd);
INSERT INTO holiday (tenant_id, ymd, name, is_substitute) VALUES
('KINTEX', DATE '2024-01-01', '신정', false),
('KINTEX', DATE '2024-02-09', '설날 연휴', false),
('KINTEX', DATE '2024-02-10', '설날', false),
('KINTEX', DATE '2024-02-11', '설날 연휴', false),
('KINTEX', DATE '2024-02-12', '대체공휴일(설날)', true),
('KINTEX', DATE '2024-03-01', '삼일절', false),
('KINTEX', DATE '2024-05-05', '어린이날', false),
('KINTEX', DATE '2024-05-06', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2024-05-15', '부처님오신날', false),
('KINTEX', DATE '2024-06-06', '현충일', false),
('KINTEX', DATE '2024-08-15', '광복절', false),
('KINTEX', DATE '2024-09-16', '추석 연휴', false),
('KINTEX', DATE '2024-09-17', '추석', false),
('KINTEX', DATE '2024-09-18', '추석 연휴', false),
('KINTEX', DATE '2024-10-03', '개천절', false),
('KINTEX', DATE '2024-10-09', '한글날', false),
('KINTEX', DATE '2024-12-25', '성탄절', false),
('KINTEX', DATE '2025-01-01', '신정', false),
('KINTEX', DATE '2025-01-28', '설날 연휴', false),
('KINTEX', DATE '2025-01-29', '설날', false),
('KINTEX', DATE '2025-01-30', '설날 연휴', false),
('KINTEX', DATE '2025-03-01', '삼일절', false),
('KINTEX', DATE '2025-05-05', '부처님오신날', false),
('KINTEX', DATE '2025-06-06', '현충일', false),
('KINTEX', DATE '2025-08-15', '광복절', false),
('KINTEX', DATE '2025-10-03', '개천절', false),
('KINTEX', DATE '2025-10-05', '추석 연휴', false),
('KINTEX', DATE '2025-10-06', '추석', false),
('KINTEX', DATE '2025-10-07', '추석 연휴', false),
('KINTEX', DATE '2025-10-08', '대체공휴일(추석)', true),
('KINTEX', DATE '2025-10-09', '한글날', false),
('KINTEX', DATE '2025-12-25', '성탄절', false),
('KINTEX', DATE '2026-01-01', '신정', false),
('KINTEX', DATE '2026-02-16', '설날 연휴', false),
('KINTEX', DATE '2026-02-17', '설날', false),
('KINTEX', DATE '2026-02-18', '설날 연휴', false),
('KINTEX', DATE '2026-03-01', '삼일절', false),
('KINTEX', DATE '2026-03-02', '대체공휴일(삼일절)', true),
('KINTEX', DATE '2026-05-05', '어린이날', false),
('KINTEX', DATE '2026-05-24', '부처님오신날', false),
('KINTEX', DATE '2026-05-25', '대체공휴일(부처님오신날)', true),
('KINTEX', DATE '2026-06-06', '현충일', false),
('KINTEX', DATE '2026-08-15', '광복절', false),
('KINTEX', DATE '2026-09-24', '추석 연휴', false),
('KINTEX', DATE '2026-09-25', '추석', false),
('KINTEX', DATE '2026-09-26', '추석 연휴', false),
('KINTEX', DATE '2026-10-03', '개천절', false),
('KINTEX', DATE '2026-10-09', '한글날', false),
('KINTEX', DATE '2026-12-25', '성탄절', false),
('KINTEX', DATE '2027-01-01', '신정', false),
('KINTEX', DATE '2027-02-06', '설날 연휴', false),
('KINTEX', DATE '2027-02-07', '설날', false),
('KINTEX', DATE '2027-02-08', '설날 연휴', false),
('KINTEX', DATE '2027-02-09', '대체공휴일(설날)', true),
('KINTEX', DATE '2027-03-01', '삼일절', false),
('KINTEX', DATE '2027-05-05', '어린이날', false),
('KINTEX', DATE '2027-05-13', '부처님오신날', false),
('KINTEX', DATE '2027-06-06', '현충일', false),
('KINTEX', DATE '2027-08-15', '광복절', false),
('KINTEX', DATE '2027-08-16', '대체공휴일(광복절)', true),
('KINTEX', DATE '2027-09-14', '추석 연휴', false),
('KINTEX', DATE '2027-09-15', '추석', false),
('KINTEX', DATE '2027-09-16', '추석 연휴', false),
('KINTEX', DATE '2027-10-03', '개천절', false),
('KINTEX', DATE '2027-10-04', '대체공휴일(개천절)', true),
('KINTEX', DATE '2027-10-09', '한글날', false),
('KINTEX', DATE '2027-12-25', '성탄절', false),
('KINTEX', DATE '2028-01-01', '신정', false),
('KINTEX', DATE '2028-01-26', '설날 연휴', false),
('KINTEX', DATE '2028-01-27', '설날', false),
('KINTEX', DATE '2028-01-28', '설날 연휴', false),
('KINTEX', DATE '2028-03-01', '삼일절', false),
('KINTEX', DATE '2028-05-02', '부처님오신날', false),
('KINTEX', DATE '2028-05-05', '어린이날', false),
('KINTEX', DATE '2028-06-06', '현충일', false),
('KINTEX', DATE '2028-08-15', '광복절', false),
('KINTEX', DATE '2028-10-02', '추석 연휴', false),
('KINTEX', DATE '2028-10-03', '추석', false),
('KINTEX', DATE '2028-10-04', '추석 연휴', false),
('KINTEX', DATE '2028-10-09', '한글날', false),
('KINTEX', DATE '2028-12-25', '성탄절', false),
('KINTEX', DATE '2029-01-01', '신정', false),
('KINTEX', DATE '2029-02-12', '설날 연휴', false),
('KINTEX', DATE '2029-02-13', '설날', false),
('KINTEX', DATE '2029-02-14', '설날 연휴', false),
('KINTEX', DATE '2029-03-01', '삼일절', false),
('KINTEX', DATE '2029-05-05', '어린이날', false),
('KINTEX', DATE '2029-05-07', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2029-05-20', '부처님오신날', false),
('KINTEX', DATE '2029-05-21', '대체공휴일(부처님오신날)', true),
('KINTEX', DATE '2029-06-06', '현충일', false),
('KINTEX', DATE '2029-08-15', '광복절', false),
('KINTEX', DATE '2029-09-21', '추석 연휴', false),
('KINTEX', DATE '2029-09-22', '추석', false),
('KINTEX', DATE '2029-09-23', '추석 연휴', false),
('KINTEX', DATE '2029-09-24', '대체공휴일(추석)', true),
('KINTEX', DATE '2029-10-03', '개천절', false),
('KINTEX', DATE '2029-10-09', '한글날', false),
('KINTEX', DATE '2029-12-25', '성탄절', false),
('KINTEX', DATE '2030-01-01', '신정', false),
('KINTEX', DATE '2030-02-02', '설날 연휴', false),
('KINTEX', DATE '2030-02-03', '설날', false),
('KINTEX', DATE '2030-02-04', '설날 연휴', false),
('KINTEX', DATE '2030-02-05', '대체공휴일(설날)', true),
('KINTEX', DATE '2030-03-01', '삼일절', false),
('KINTEX', DATE '2030-05-05', '어린이날', false),
('KINTEX', DATE '2030-05-06', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2030-05-09', '부처님오신날', false),
('KINTEX', DATE '2030-06-06', '현충일', false),
('KINTEX', DATE '2030-08-15', '광복절', false),
('KINTEX', DATE '2030-09-11', '추석 연휴', false),
('KINTEX', DATE '2030-09-12', '추석', false),
('KINTEX', DATE '2030-09-13', '추석 연휴', false),
('KINTEX', DATE '2030-10-03', '개천절', false),
('KINTEX', DATE '2030-10-09', '한글날', false),
('KINTEX', DATE '2030-12-25', '성탄절', false),
('KINTEX', DATE '2031-01-01', '신정', false),
('KINTEX', DATE '2031-01-22', '설날 연휴', false),
('KINTEX', DATE '2031-01-23', '설날', false),
('KINTEX', DATE '2031-01-24', '설날 연휴', false),
('KINTEX', DATE '2031-03-01', '삼일절', false),
('KINTEX', DATE '2031-05-05', '어린이날', false),
('KINTEX', DATE '2031-05-28', '부처님오신날', false),
('KINTEX', DATE '2031-06-06', '현충일', false),
('KINTEX', DATE '2031-08-15', '광복절', false),
('KINTEX', DATE '2031-09-30', '추석 연휴', false),
('KINTEX', DATE '2031-10-01', '추석', false),
('KINTEX', DATE '2031-10-02', '추석 연휴', false),
('KINTEX', DATE '2031-10-03', '개천절', false),
('KINTEX', DATE '2031-10-09', '한글날', false),
('KINTEX', DATE '2031-12-25', '성탄절', false),
('KINTEX', DATE '2032-01-01', '신정', false),
('KINTEX', DATE '2032-02-10', '설날 연휴', false),
('KINTEX', DATE '2032-02-11', '설날', false),
('KINTEX', DATE '2032-02-12', '설날 연휴', false),
('KINTEX', DATE '2032-03-01', '삼일절', false),
('KINTEX', DATE '2032-05-05', '어린이날', false),
('KINTEX', DATE '2032-05-16', '부처님오신날', false),
('KINTEX', DATE '2032-05-17', '대체공휴일(부처님오신날)', true),
('KINTEX', DATE '2032-06-06', '현충일', false),
('KINTEX', DATE '2032-08-15', '광복절', false),
('KINTEX', DATE '2032-08-16', '대체공휴일(광복절)', true),
('KINTEX', DATE '2032-09-18', '추석 연휴', false),
('KINTEX', DATE '2032-09-19', '추석', false),
('KINTEX', DATE '2032-09-20', '추석 연휴', false),
('KINTEX', DATE '2032-09-21', '대체공휴일(추석)', true),
('KINTEX', DATE '2032-10-03', '개천절', false),
('KINTEX', DATE '2032-10-04', '대체공휴일(개천절)', true),
('KINTEX', DATE '2032-10-09', '한글날', false),
('KINTEX', DATE '2032-12-25', '성탄절', false),
('KINTEX', DATE '2033-01-01', '신정', false),
('KINTEX', DATE '2033-01-30', '설날 연휴', false),
('KINTEX', DATE '2033-01-31', '설날', false),
('KINTEX', DATE '2033-02-01', '설날 연휴', false),
('KINTEX', DATE '2033-02-02', '대체공휴일(설날)', true),
('KINTEX', DATE '2033-03-01', '삼일절', false),
('KINTEX', DATE '2033-05-05', '어린이날', false),
('KINTEX', DATE '2033-05-06', '부처님오신날', false),
('KINTEX', DATE '2033-06-06', '현충일', false),
('KINTEX', DATE '2033-08-15', '광복절', false),
('KINTEX', DATE '2033-09-07', '추석 연휴', false),
('KINTEX', DATE '2033-09-08', '추석', false),
('KINTEX', DATE '2033-09-09', '추석 연휴', false),
('KINTEX', DATE '2033-10-03', '개천절', false),
('KINTEX', DATE '2033-10-09', '한글날', false),
('KINTEX', DATE '2033-10-10', '대체공휴일(한글날)', true),
('KINTEX', DATE '2033-12-25', '성탄절', false),
('KINTEX', DATE '2033-12-26', '대체공휴일(성탄절)', true),
('KINTEX', DATE '2034-01-01', '신정', false),
('KINTEX', DATE '2034-02-18', '설날 연휴', false),
('KINTEX', DATE '2034-02-19', '설날', false),
('KINTEX', DATE '2034-02-20', '설날 연휴', false),
('KINTEX', DATE '2034-02-21', '대체공휴일(설날)', true),
('KINTEX', DATE '2034-03-01', '삼일절', false),
('KINTEX', DATE '2034-05-05', '어린이날', false),
('KINTEX', DATE '2034-05-25', '부처님오신날', false),
('KINTEX', DATE '2034-06-06', '현충일', false),
('KINTEX', DATE '2034-08-15', '광복절', false),
('KINTEX', DATE '2034-09-26', '추석 연휴', false),
('KINTEX', DATE '2034-09-27', '추석', false),
('KINTEX', DATE '2034-09-28', '추석 연휴', false),
('KINTEX', DATE '2034-10-03', '개천절', false),
('KINTEX', DATE '2034-10-09', '한글날', false),
('KINTEX', DATE '2034-12-25', '성탄절', false),
('KINTEX', DATE '2035-01-01', '신정', false),
('KINTEX', DATE '2035-02-07', '설날 연휴', false),
('KINTEX', DATE '2035-02-08', '설날', false),
('KINTEX', DATE '2035-02-09', '설날 연휴', false),
('KINTEX', DATE '2035-03-01', '삼일절', false),
('KINTEX', DATE '2035-05-05', '어린이날', false),
('KINTEX', DATE '2035-05-07', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2035-05-15', '부처님오신날', false),
('KINTEX', DATE '2035-06-06', '현충일', false),
('KINTEX', DATE '2035-08-15', '광복절', false),
('KINTEX', DATE '2035-09-15', '추석 연휴', false),
('KINTEX', DATE '2035-09-16', '추석', false),
('KINTEX', DATE '2035-09-17', '추석 연휴', false),
('KINTEX', DATE '2035-09-18', '대체공휴일(추석)', true),
('KINTEX', DATE '2035-10-03', '개천절', false),
('KINTEX', DATE '2035-10-09', '한글날', false),
('KINTEX', DATE '2035-12-25', '성탄절', false),
('KINTEX', DATE '2036-01-01', '신정', false),
('KINTEX', DATE '2036-01-27', '설날 연휴', false),
('KINTEX', DATE '2036-01-28', '설날', false),
('KINTEX', DATE '2036-01-29', '설날 연휴', false),
('KINTEX', DATE '2036-01-30', '대체공휴일(설날)', true),
('KINTEX', DATE '2036-03-01', '삼일절', false),
('KINTEX', DATE '2036-05-03', '부처님오신날', false),
('KINTEX', DATE '2036-05-05', '어린이날', false),
('KINTEX', DATE '2036-06-06', '현충일', false),
('KINTEX', DATE '2036-08-15', '광복절', false),
('KINTEX', DATE '2036-10-03', '추석 연휴', false),
('KINTEX', DATE '2036-10-04', '추석', false),
('KINTEX', DATE '2036-10-05', '추석 연휴', false),
('KINTEX', DATE '2036-10-06', '대체공휴일(추석)', true),
('KINTEX', DATE '2036-10-09', '한글날', false),
('KINTEX', DATE '2036-12-25', '성탄절', false),
('KINTEX', DATE '2037-01-01', '신정', false),
('KINTEX', DATE '2037-02-14', '설날 연휴', false),
('KINTEX', DATE '2037-02-15', '설날', false),
('KINTEX', DATE '2037-02-16', '설날 연휴', false),
('KINTEX', DATE '2037-02-17', '대체공휴일(설날)', true),
('KINTEX', DATE '2037-03-01', '삼일절', false),
('KINTEX', DATE '2037-03-02', '대체공휴일(삼일절)', true),
('KINTEX', DATE '2037-05-05', '어린이날', false),
('KINTEX', DATE '2037-05-22', '부처님오신날', false),
('KINTEX', DATE '2037-06-06', '현충일', false),
('KINTEX', DATE '2037-08-15', '광복절', false),
('KINTEX', DATE '2037-09-23', '추석 연휴', false),
('KINTEX', DATE '2037-09-24', '추석', false),
('KINTEX', DATE '2037-09-25', '추석 연휴', false),
('KINTEX', DATE '2037-10-03', '개천절', false),
('KINTEX', DATE '2037-10-09', '한글날', false),
('KINTEX', DATE '2037-12-25', '성탄절', false),
('KINTEX', DATE '2038-01-01', '신정', false),
('KINTEX', DATE '2038-02-03', '설날 연휴', false),
('KINTEX', DATE '2038-02-04', '설날', false),
('KINTEX', DATE '2038-02-05', '설날 연휴', false),
('KINTEX', DATE '2038-03-01', '삼일절', false),
('KINTEX', DATE '2038-05-05', '어린이날', false),
('KINTEX', DATE '2038-05-11', '부처님오신날', false),
('KINTEX', DATE '2038-06-06', '현충일', false),
('KINTEX', DATE '2038-08-15', '광복절', false),
('KINTEX', DATE '2038-08-16', '대체공휴일(광복절)', true),
('KINTEX', DATE '2038-09-12', '추석 연휴', false),
('KINTEX', DATE '2038-09-13', '추석', false),
('KINTEX', DATE '2038-09-14', '추석 연휴', false),
('KINTEX', DATE '2038-09-15', '대체공휴일(추석)', true),
('KINTEX', DATE '2038-10-03', '개천절', false),
('KINTEX', DATE '2038-10-04', '대체공휴일(개천절)', true),
('KINTEX', DATE '2038-10-09', '한글날', false),
('KINTEX', DATE '2038-12-25', '성탄절', false),
('KINTEX', DATE '2039-01-01', '신정', false),
('KINTEX', DATE '2039-01-23', '설날 연휴', false),
('KINTEX', DATE '2039-01-24', '설날', false),
('KINTEX', DATE '2039-01-25', '설날 연휴', false),
('KINTEX', DATE '2039-01-26', '대체공휴일(설날)', true),
('KINTEX', DATE '2039-03-01', '삼일절', false),
('KINTEX', DATE '2039-04-30', '부처님오신날', false),
('KINTEX', DATE '2039-05-05', '어린이날', false),
('KINTEX', DATE '2039-06-06', '현충일', false),
('KINTEX', DATE '2039-08-15', '광복절', false),
('KINTEX', DATE '2039-10-01', '추석 연휴', false),
('KINTEX', DATE '2039-10-02', '추석', false),
('KINTEX', DATE '2039-10-03', '추석 연휴', false),
('KINTEX', DATE '2039-10-04', '대체공휴일(추석)', true),
('KINTEX', DATE '2039-10-09', '한글날', false),
('KINTEX', DATE '2039-10-10', '대체공휴일(한글날)', true),
('KINTEX', DATE '2039-12-25', '성탄절', false),
('KINTEX', DATE '2039-12-26', '대체공휴일(성탄절)', true),
('KINTEX', DATE '2040-01-01', '신정', false),
('KINTEX', DATE '2040-02-11', '설날 연휴', false),
('KINTEX', DATE '2040-02-12', '설날', false),
('KINTEX', DATE '2040-02-13', '설날 연휴', false),
('KINTEX', DATE '2040-02-14', '대체공휴일(설날)', true),
('KINTEX', DATE '2040-03-01', '삼일절', false),
('KINTEX', DATE '2040-05-05', '어린이날', false),
('KINTEX', DATE '2040-05-07', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2040-05-18', '부처님오신날', false),
('KINTEX', DATE '2040-06-06', '현충일', false),
('KINTEX', DATE '2040-08-15', '광복절', false),
('KINTEX', DATE '2040-09-20', '추석 연휴', false),
('KINTEX', DATE '2040-09-21', '추석', false),
('KINTEX', DATE '2040-09-22', '추석 연휴', false),
('KINTEX', DATE '2040-10-03', '개천절', false),
('KINTEX', DATE '2040-10-09', '한글날', false),
('KINTEX', DATE '2040-12-25', '성탄절', false),
('KINTEX', DATE '2041-01-01', '신정', false),
('KINTEX', DATE '2041-01-31', '설날 연휴', false),
('KINTEX', DATE '2041-02-01', '설날', false),
('KINTEX', DATE '2041-02-02', '설날 연휴', false),
('KINTEX', DATE '2041-03-01', '삼일절', false),
('KINTEX', DATE '2041-05-05', '어린이날', false),
('KINTEX', DATE '2041-05-06', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2041-05-07', '부처님오신날', false),
('KINTEX', DATE '2041-06-06', '현충일', false),
('KINTEX', DATE '2041-08-15', '광복절', false),
('KINTEX', DATE '2041-09-09', '추석 연휴', false),
('KINTEX', DATE '2041-09-10', '추석', false),
('KINTEX', DATE '2041-09-11', '추석 연휴', false),
('KINTEX', DATE '2041-10-03', '개천절', false),
('KINTEX', DATE '2041-10-09', '한글날', false),
('KINTEX', DATE '2041-12-25', '성탄절', false),
('KINTEX', DATE '2042-01-01', '신정', false),
('KINTEX', DATE '2042-01-21', '설날 연휴', false),
('KINTEX', DATE '2042-01-22', '설날', false),
('KINTEX', DATE '2042-01-23', '설날 연휴', false),
('KINTEX', DATE '2042-03-01', '삼일절', false),
('KINTEX', DATE '2042-05-05', '어린이날', false),
('KINTEX', DATE '2042-05-26', '부처님오신날', false),
('KINTEX', DATE '2042-06-06', '현충일', false),
('KINTEX', DATE '2042-08-15', '광복절', false),
('KINTEX', DATE '2042-09-27', '추석 연휴', false),
('KINTEX', DATE '2042-09-28', '추석', false),
('KINTEX', DATE '2042-09-29', '추석 연휴', false),
('KINTEX', DATE '2042-09-30', '대체공휴일(추석)', true),
('KINTEX', DATE '2042-10-03', '개천절', false),
('KINTEX', DATE '2042-10-09', '한글날', false),
('KINTEX', DATE '2042-12-25', '성탄절', false),
('KINTEX', DATE '2043-01-01', '신정', false),
('KINTEX', DATE '2043-02-09', '설날 연휴', false),
('KINTEX', DATE '2043-02-10', '설날', false),
('KINTEX', DATE '2043-02-11', '설날 연휴', false),
('KINTEX', DATE '2043-03-01', '삼일절', false),
('KINTEX', DATE '2043-03-02', '대체공휴일(삼일절)', true),
('KINTEX', DATE '2043-05-05', '어린이날', false),
('KINTEX', DATE '2043-05-16', '부처님오신날', false),
('KINTEX', DATE '2043-06-06', '현충일', false),
('KINTEX', DATE '2043-08-15', '광복절', false),
('KINTEX', DATE '2043-09-16', '추석 연휴', false),
('KINTEX', DATE '2043-09-17', '추석', false),
('KINTEX', DATE '2043-09-18', '추석 연휴', false),
('KINTEX', DATE '2043-10-03', '개천절', false),
('KINTEX', DATE '2043-10-09', '한글날', false),
('KINTEX', DATE '2043-12-25', '성탄절', false),
('KINTEX', DATE '2044-01-01', '신정', false),
('KINTEX', DATE '2044-01-29', '설날 연휴', false),
('KINTEX', DATE '2044-01-30', '설날', false),
('KINTEX', DATE '2044-01-31', '설날 연휴', false),
('KINTEX', DATE '2044-02-01', '대체공휴일(설날)', true),
('KINTEX', DATE '2044-03-01', '삼일절', false),
('KINTEX', DATE '2044-05-05', '부처님오신날', false),
('KINTEX', DATE '2044-06-06', '현충일', false),
('KINTEX', DATE '2044-08-15', '광복절', false),
('KINTEX', DATE '2044-10-03', '개천절', false),
('KINTEX', DATE '2044-10-04', '추석 연휴', false),
('KINTEX', DATE '2044-10-05', '추석', false),
('KINTEX', DATE '2044-10-06', '추석 연휴', false),
('KINTEX', DATE '2044-10-09', '한글날', false),
('KINTEX', DATE '2044-10-10', '대체공휴일(한글날)', true),
('KINTEX', DATE '2044-12-25', '성탄절', false),
('KINTEX', DATE '2044-12-26', '대체공휴일(성탄절)', true),
('KINTEX', DATE '2045-01-01', '신정', false),
('KINTEX', DATE '2045-02-16', '설날 연휴', false),
('KINTEX', DATE '2045-02-17', '설날', false),
('KINTEX', DATE '2045-02-18', '설날 연휴', false),
('KINTEX', DATE '2045-03-01', '삼일절', false),
('KINTEX', DATE '2045-05-05', '어린이날', false),
('KINTEX', DATE '2045-05-24', '부처님오신날', false),
('KINTEX', DATE '2045-06-06', '현충일', false),
('KINTEX', DATE '2045-08-15', '광복절', false),
('KINTEX', DATE '2045-09-24', '추석 연휴', false),
('KINTEX', DATE '2045-09-25', '추석', false),
('KINTEX', DATE '2045-09-26', '추석 연휴', false),
('KINTEX', DATE '2045-09-27', '대체공휴일(추석)', true),
('KINTEX', DATE '2045-10-03', '개천절', false),
('KINTEX', DATE '2045-10-09', '한글날', false),
('KINTEX', DATE '2045-12-25', '성탄절', false),
('KINTEX', DATE '2046-01-01', '신정', false),
('KINTEX', DATE '2046-02-05', '설날 연휴', false),
('KINTEX', DATE '2046-02-06', '설날', false),
('KINTEX', DATE '2046-02-07', '설날 연휴', false),
('KINTEX', DATE '2046-03-01', '삼일절', false),
('KINTEX', DATE '2046-05-05', '어린이날', false),
('KINTEX', DATE '2046-05-07', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2046-05-13', '부처님오신날', false),
('KINTEX', DATE '2046-05-14', '대체공휴일(부처님오신날)', true),
('KINTEX', DATE '2046-06-06', '현충일', false),
('KINTEX', DATE '2046-08-15', '광복절', false),
('KINTEX', DATE '2046-09-14', '추석 연휴', false),
('KINTEX', DATE '2046-09-15', '추석', false),
('KINTEX', DATE '2046-09-16', '추석 연휴', false),
('KINTEX', DATE '2046-09-17', '대체공휴일(추석)', true),
('KINTEX', DATE '2046-10-03', '개천절', false),
('KINTEX', DATE '2046-10-09', '한글날', false),
('KINTEX', DATE '2046-12-25', '성탄절', false),
('KINTEX', DATE '2047-01-01', '신정', false),
('KINTEX', DATE '2047-01-25', '설날 연휴', false),
('KINTEX', DATE '2047-01-26', '설날', false),
('KINTEX', DATE '2047-01-27', '설날 연휴', false),
('KINTEX', DATE '2047-01-28', '대체공휴일(설날)', true),
('KINTEX', DATE '2047-03-01', '삼일절', false),
('KINTEX', DATE '2047-05-02', '부처님오신날', false),
('KINTEX', DATE '2047-05-05', '어린이날', false),
('KINTEX', DATE '2047-05-06', '대체공휴일(어린이날)', true),
('KINTEX', DATE '2047-06-06', '현충일', false),
('KINTEX', DATE '2047-08-15', '광복절', false),
('KINTEX', DATE '2047-10-03', '추석 연휴', false),
('KINTEX', DATE '2047-10-04', '추석', false),
('KINTEX', DATE '2047-10-05', '추석 연휴', false),
('KINTEX', DATE '2047-10-09', '한글날', false),
('KINTEX', DATE '2047-12-25', '성탄절', false),
('KINTEX', DATE '2048-01-01', '신정', false),
('KINTEX', DATE '2048-02-13', '설날 연휴', false),
('KINTEX', DATE '2048-02-14', '설날', false),
('KINTEX', DATE '2048-02-15', '설날 연휴', false),
('KINTEX', DATE '2048-03-01', '삼일절', false),
('KINTEX', DATE '2048-03-02', '대체공휴일(삼일절)', true),
('KINTEX', DATE '2048-05-05', '어린이날', false),
('KINTEX', DATE '2048-05-20', '부처님오신날', false),
('KINTEX', DATE '2048-06-06', '현충일', false),
('KINTEX', DATE '2048-08-15', '광복절', false),
('KINTEX', DATE '2048-09-21', '추석 연휴', false),
('KINTEX', DATE '2048-09-22', '추석', false),
('KINTEX', DATE '2048-09-23', '추석 연휴', false),
('KINTEX', DATE '2048-10-03', '개천절', false),
('KINTEX', DATE '2048-10-09', '한글날', false),
('KINTEX', DATE '2048-12-25', '성탄절', false),
('KINTEX', DATE '2049-01-01', '신정', false),
('KINTEX', DATE '2049-02-01', '설날 연휴', false),
('KINTEX', DATE '2049-02-02', '설날', false),
('KINTEX', DATE '2049-02-03', '설날 연휴', false),
('KINTEX', DATE '2049-03-01', '삼일절', false),
('KINTEX', DATE '2049-05-05', '어린이날', false),
('KINTEX', DATE '2049-05-09', '부처님오신날', false),
('KINTEX', DATE '2049-05-10', '대체공휴일(부처님오신날)', true),
('KINTEX', DATE '2049-06-06', '현충일', false),
('KINTEX', DATE '2049-08-15', '광복절', false),
('KINTEX', DATE '2049-08-16', '대체공휴일(광복절)', true),
('KINTEX', DATE '2049-09-10', '추석 연휴', false),
('KINTEX', DATE '2049-09-11', '추석', false),
('KINTEX', DATE '2049-09-12', '추석 연휴', false),
('KINTEX', DATE '2049-09-13', '대체공휴일(추석)', true),
('KINTEX', DATE '2049-10-03', '개천절', false),
('KINTEX', DATE '2049-10-04', '대체공휴일(개천절)', true),
('KINTEX', DATE '2049-10-09', '한글날', false),
('KINTEX', DATE '2049-12-25', '성탄절', false),
('KINTEX', DATE '2050-01-01', '신정', false),
('KINTEX', DATE '2050-01-22', '설날 연휴', false),
('KINTEX', DATE '2050-01-23', '설날', false),
('KINTEX', DATE '2050-01-24', '설날 연휴', false),
('KINTEX', DATE '2050-01-25', '대체공휴일(설날)', true),
('KINTEX', DATE '2050-03-01', '삼일절', false),
('KINTEX', DATE '2050-05-05', '어린이날', false),
('KINTEX', DATE '2050-05-28', '부처님오신날', false),
('KINTEX', DATE '2050-06-06', '현충일', false),
('KINTEX', DATE '2050-08-15', '광복절', false),
('KINTEX', DATE '2050-09-29', '추석 연휴', false),
('KINTEX', DATE '2050-09-30', '추석', false),
('KINTEX', DATE '2050-10-01', '추석 연휴', false),
('KINTEX', DATE '2050-10-03', '개천절', false),
('KINTEX', DATE '2050-10-09', '한글날', false),
('KINTEX', DATE '2050-10-10', '대체공휴일(한글날)', true),
('KINTEX', DATE '2050-12-25', '성탄절', false),
('KINTEX', DATE '2050-12-26', '대체공휴일(성탄절)', true)
ON CONFLICT (tenant_id, ymd) DO NOTHING;

View File

@ -24,6 +24,7 @@ import { LoginHistoryPage } from './screens/admin/LoginHistoryPage';
import { ErrorLogPage } from './screens/admin/ErrorLogPage'; import { ErrorLogPage } from './screens/admin/ErrorLogPage';
import { SystemHealthPage } from './screens/admin/SystemHealthPage'; import { SystemHealthPage } from './screens/admin/SystemHealthPage';
import { MailConfigPage, NotifyConfigPage } from './screens/admin/InfraConfigPage'; import { MailConfigPage, NotifyConfigPage } from './screens/admin/InfraConfigPage';
import { HolidayAdminPage } from './screens/admin/HolidayAdminPage';
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';
@ -242,6 +243,7 @@ export function App() {
<Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} /> <Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} />
<Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} /> <Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} />
<Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} /> <Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} />
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
</Route> </Route>
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */} {/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}

View File

@ -180,6 +180,7 @@ const GROUPS: NavGroupDef[] = [
{ key: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true }, { 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-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-rulesets', label: '룰셋', labelKey: 'shell.menu.items.rulesets', Icon: IconOperations, to: '/admin/masterdata/rulesets' },
{ key: 'admin-holidays', label: '공휴일 관리', labelKey: 'shell.menu.items.holidays', Icon: IconCalendar, to: '/admin/holidays' },
{ key: 'admin-slides', label: '로그인 슬라이드', labelKey: 'shell.menu.items.slides', Icon: IconBell, to: '/admin/login-slides' }, { key: 'admin-slides', label: '로그인 슬라이드', labelKey: 'shell.menu.items.slides', Icon: IconBell, to: '/admin/login-slides' },
], ],
}, },
@ -447,10 +448,20 @@ export function AppShell() {
className="kx-shell__icon-btn" className="kx-shell__icon-btn"
type="button" type="button"
aria-label="알림" aria-label="알림"
title="알림"
onClick={() => navigate('/notifications')} onClick={() => navigate('/notifications')}
> >
<IconBell size={20} /> <IconBell size={20} />
</button> </button>
<button
className="kx-shell__icon-btn"
type="button"
aria-label="설정"
title="설정"
onClick={() => navigate(isAdmin ? '/admin/settings' : '/me')}
>
<IconSettings size={20} />
</button>
</div> </div>
</header> </header>

View File

@ -15,6 +15,7 @@ export function MdiTabBar() {
const tabs = useMdiStore((s) => s.tabs); const tabs = useMdiStore((s) => s.tabs);
const activePath = useMdiStore((s) => s.activePath); const activePath = useMdiStore((s) => s.activePath);
const closeTab = useMdiStore((s) => s.closeTab); const closeTab = useMdiStore((s) => s.closeTab);
const closeAll = useMdiStore((s) => s.closeAll);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]); const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
@ -79,6 +80,21 @@ export function MdiTabBar() {
</div> </div>
); );
})} })}
{tabs.length > 1 && (
<button
type="button"
className="kx-mdi__closeall"
title="모든 탭 닫기"
aria-label="모든 탭 닫기"
onClick={() => {
const to = closeAll();
if (to !== location.pathname) navigate(to);
}}
>
<IconClose size={13} />
<span className="kx-mdi__closeall-label"> </span>
</button>
)}
</div> </div>
); );
} }

View File

@ -570,3 +570,26 @@
padding: 0 var(--space-4); padding: 0 var(--space-4);
} }
} }
/* MDI 모두 닫기 버튼 — 탭 스트립 우측, 2개 이상일 때만 노출 */
.kx-mdi__closeall {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding: 0 10px;
height: 28px;
align-self: center;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
background: transparent;
border: 1px solid var(--border-card);
border-radius: var(--radius-pill);
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.kx-mdi__closeall:hover {
color: var(--color-error);
border-color: var(--color-error);
}

View File

@ -0,0 +1,154 @@
/*
* Offcanvas( ) ()/ · .
* 레퍼런스: Nifty offcanvas (docs/DESIGN_SYSTEM_NIFTY.md §2.6).
*
*
* - (· ) · (· X) · (scroll)
* - Esc · (Tab ) · ,
* - CSS에서 `prefers-reduced-motion` ( )
* - role=dialog · aria-modal · aria-label(title)
* - kx , ( CSS )
*
* :
* <Offcanvas open={open} onClose={() => setOpen(false)} title="배정 상세">
*
* </Offcanvas>
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { IconClose } from './icons';
import './offcanvas.css';
export interface OffcanvasProps {
/** 열림 여부. */
open: boolean;
/** 닫기 콜백(백드롭 클릭·Esc·닫기 버튼). */
onClose: () => void;
/** 헤더 제목(aria-label에도 사용). */
title: string;
/** 슬라이드 방향. 기본 'right'. */
side?: 'right' | 'left';
/** 패널 너비(CSS 길이). 기본 420px. */
width?: number | string;
/** 바디 내용. */
children: ReactNode;
/** 헤더 하단(제목 옆) 부가 액션 슬롯(선택). */
headerExtra?: ReactNode;
/** 하단 고정 푸터(선택). */
footer?: ReactNode;
}
/** 슬라이드 아웃 유지 시간(ms) — CSS transition 과 정렬. reduced-motion 이어도 안전. */
const EXIT_MS = 240;
export function Offcanvas({
open,
onClose,
title,
side = 'right',
width = 420,
children,
headerExtra,
footer,
}: OffcanvasProps) {
// mounted: DOM 유지 여부(닫힘 트랜지션 동안 true 유지) / visible: is-open 클래스(transform 구동)
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const prevFocusRef = useRef<HTMLElement | null>(null);
// 열림/닫힘 → mount·visible 전환
useEffect(() => {
if (open) {
prevFocusRef.current = document.activeElement as HTMLElement | null;
setMounted(true);
// 다음 프레임에 is-open 부여 → 슬라이드 인
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}
setVisible(false);
const timer = window.setTimeout(() => setMounted(false), EXIT_MS);
return () => window.clearTimeout(timer);
}, [open]);
// 열린 뒤 패널로 포커스 이동 · 닫히면 이전 포커스 복원
useEffect(() => {
if (visible) {
panelRef.current?.focus();
} else if (prevFocusRef.current) {
prevFocusRef.current.focus?.();
}
}, [visible]);
// 열려있는 동안 body 스크롤 잠금
useEffect(() => {
if (!mounted) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [mounted]);
// Esc 닫기 + 포커스 트랩
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
return;
}
if (e.key !== 'Tab') return;
const panel = panelRef.current;
if (!panel) return;
const focusables = panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || active === panel) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
},
[onClose],
);
if (!mounted) return null;
return (
<div className={`kx-oc${visible ? ' is-open' : ''}`}>
<div className="kx-oc__backdrop" onClick={onClose} aria-hidden="true" />
<div
ref={panelRef}
className={`kx-oc__panel kx-oc__panel--${side}`}
style={{ width: typeof width === 'number' ? `${width}px` : width }}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
onKeyDown={onKeyDown}
>
<header className="kx-oc__head">
<h2 className="kx-oc__title">{title}</h2>
{headerExtra && <div className="kx-oc__headextra">{headerExtra}</div>}
<button type="button" className="kx-oc__close" aria-label={title ? `${title} 닫기` : '닫기'} onClick={onClose}>
<IconClose size={20} />
</button>
</header>
<div className="kx-oc__body">{children}</div>
{footer && <footer className="kx-oc__foot">{footer}</footer>}
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
/*
* 공용 Offcanvas 드로어 스타일 Nifty offcanvas 패턴(DESIGN_SYSTEM_NIFTY §2.6).
* 전역 디자인 토큰(--color-*·--space-*·--radius-*·--fs-*) 사용. kx-oc__* 네임스페이스.
*/
.kx-oc {
position: fixed;
inset: 0;
z-index: 60;
}
/* 백드롭 — 페이드 인/아웃 */
.kx-oc__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.45);
opacity: 0;
transition: opacity 0.24s ease;
}
.kx-oc.is-open .kx-oc__backdrop {
opacity: 1;
}
/* 패널 — 우/좌에서 슬라이드 */
.kx-oc__panel {
position: absolute;
top: 0;
bottom: 0;
max-width: 92vw;
display: flex;
flex-direction: column;
background: var(--color-white);
box-shadow: var(--shadow-lg, 0 12px 32px rgba(0, 0, 0, 0.22));
outline: none;
transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
.kx-oc__panel--right {
right: 0;
border-left: var(--border-card);
transform: translateX(100%);
}
.kx-oc__panel--left {
left: 0;
border-right: var(--border-card);
transform: translateX(-100%);
}
.kx-oc.is-open .kx-oc__panel {
transform: translateX(0);
}
/* 헤더 */
.kx-oc__head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
border-bottom: var(--border-card);
flex: 0 0 auto;
}
.kx-oc__title {
flex: 1 1 auto;
min-width: 0;
font-size: var(--fs-h3, var(--fs-body));
font-weight: 600;
color: var(--color-neutral-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-oc__headextra {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-oc__close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-neutral-500);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.kx-oc__close:hover {
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
.kx-oc__close:focus-visible {
outline: 2px solid var(--color-brand-500, #1f29fc);
outline-offset: 1px;
}
/* 바디(스크롤) */
.kx-oc__body {
flex: 1 1 auto;
overflow-y: auto;
padding: var(--space-4);
}
/* 푸터(고정) */
.kx-oc__foot {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: var(--border-card);
}
/* 모션 최소화 존중 — 슬라이드/페이드 즉시 */
@media (prefers-reduced-motion: reduce) {
.kx-oc__backdrop,
.kx-oc__panel {
transition: none;
}
}

View File

@ -1080,7 +1080,20 @@
"rentalSubtotal": "Rental subtotal", "rentalSubtotal": "Rental subtotal",
"rentalTotal": "Rental total ({{days}} days)", "rentalTotal": "Rental total ({{days}} days)",
"deposit": "Management deposit (separate)", "deposit": "Management deposit (separate)",
"ruleset": "(ruleset {{v}})" "ruleset": "(ruleset {{v}})",
"openDetail": "Open assignment detail for {{event}}",
"detailTitle": "Assignment detail",
"edit": "Edit",
"dHall": "Hall",
"dPeriod": "Occupancy period",
"dStatus": "Status",
"dNote": "Note",
"primaryTag": "Primary hall",
"dConflict": "Conflicts",
"dNoConflict": "No overlapping assignments.",
"dQuote": "Auto quote summary",
"dQuoteNoPeriod": "No occupancy period — quote unavailable.",
"dQuoteFail": "Could not load quote."
}, },
"ai": { "ai": {
"console": { "console": {
@ -1701,7 +1714,68 @@
"demoFootNote": "Select a document from the approval inbox to approve or reject.", "demoFootNote": "Select a document from the approval inbox to approve or reject.",
"demoHint": "No approval document selected. Please open one from the approval inbox.", "demoHint": "No approval document selected. Please open one from the approval inbox.",
"commentPlaceholder": "Enter a comment (required to reject)", "commentPlaceholder": "Enter a comment (required to reject)",
"comment": "Comment" "comment": "Comment",
"boxTitle": "Approvals",
"compose": "New Draft",
"composeTitle": "New Approval Draft",
"resubmitTitle": "Resubmit Rejected Document",
"backToList": "Back to list",
"tab": {
"PENDING": "To Approve",
"DRAFTED": "My Drafts",
"REJECTED": "Rejected",
"DONE": "Completed"
},
"empty": {
"PENDING": "No documents to approve",
"DRAFTED": "No submitted documents",
"REJECTED": "No rejected documents",
"DONE": "No completed documents"
},
"resubmitBtn": "Resubmit",
"draftSave": "Save Draft",
"submitDoc": "Submit",
"cancel": "Cancel",
"saving": "Processing…",
"docType": "Document Type",
"docTypes": {
"GENERAL": "General",
"DESIGN": "Design/Drawing",
"DOCUMENT": "Document",
"EXPENSE": "Expense",
"ETC": "Other"
},
"titleLabel": "Title",
"titlePlaceholder": "Enter the document title",
"contentLabel": "Content",
"contentPlaceholder": "Enter the document content",
"err": {
"titleRequired": "Please enter a title.",
"lineRequired": "Add at least one approver to the line."
},
"createdDraft": "Saved as draft.",
"submittedDone": "Submitted for approval.",
"resubmittedDone": "Resubmitted for approval.",
"rejectedReason": "Rejection Reason",
"canResubmitHint": "This draft was rejected. You can edit and resubmit it.",
"lineEditor": {
"empty": "No approvers yet. Add an approver.",
"stepNo": "Step {{n}}",
"idPlaceholder": "Approver ID",
"namePlaceholder": "Display name (optional)",
"approverId": "Step {{n}} approver ID",
"approverName": "Step {{n}} approver name",
"moveUp": "Move up",
"moveDown": "Move down",
"remove": "Remove approver",
"add": "Add approver"
},
"rejectModalTitle": "Reject Document",
"approveModalTitle": "Approve Document",
"rejectReason": "Rejection reason",
"rejectReasonPlaceholder": "Enter the rejection reason",
"rejectReasonHint": "The reason is sent to the drafter and shown on resubmission.",
"commentOptional": "Enter a comment (optional)"
}, },
"auction": { "auction": {
"boq": { "boq": {
@ -1887,7 +1961,21 @@
"errRequired": "Please enter a department name.", "errRequired": "Please enter a department name.",
"loadError": "Failed to load the department list.", "loadError": "Failed to load the department list.",
"emptyTitle": "No departments registered", "emptyTitle": "No departments registered",
"emptyDesc": "Please add a top-level department." "emptyDesc": "Please add a top-level department.",
"searchPlaceholder": "Search departments",
"searchNoResult": "No matching departments.",
"treeAria": "Department organization tree",
"expand": "Expand",
"collapse": "Collapse",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"selectHint": "Select a department from the tree.",
"colId": "Department ID",
"colParent": "Parent department",
"topLevel": "Top level",
"colChildCount": "Sub-departments",
"childCountValue": "{{n}}",
"errHasChildren": "Cannot delete a department that has sub-departments."
}, },
"errorLog": { "errorLog": {
"title": "Error Log", "title": "Error Log",
@ -2000,5 +2088,57 @@
"home": { "home": {
"heroTitle": "The future of exhibitions, designed by AI" "heroTitle": "The future of exhibitions, designed by AI"
} }
},
"calendar": {
"prev": "Prev",
"next": "Next",
"today": "Today",
"yearMonth": "{{y}}-{{m}}",
"viewSwitch": "Switch view",
"view": {
"month": "Month",
"week": "Week",
"day": "Day"
},
"loading": "Loading…",
"legend": "Legend",
"dayEmpty": "No events.",
"dayAll": "All events on {{date}}",
"close": "Close"
},
"holiday": {
"title": "Holidays",
"subtitle": "View statutory/substitute holidays and manage temporary holidays. Shown in red on the calendar.",
"year": "Year",
"yearLabel": "{{y}}",
"add": "Add Holiday",
"created": "Holiday added.",
"deleted": "Deleted.",
"toggled": "Holiday status updated.",
"loadError": "Failed to load holidays.",
"emptyTitle": "No holidays",
"emptyDesc": "No holidays registered for the selected year.",
"listOf": "Holidays in {{y}}",
"count": "{{n}} days off",
"date": "Date",
"weekday": "Day",
"name": "Name",
"type": "Type",
"status": "Status",
"manage": "Manage",
"substitute": "Substitute",
"legal": "Statutory",
"on": "On",
"off": "Off",
"setOff": "Set working",
"setOn": "Set day off",
"delete": "Delete",
"deleteConfirm": "Delete '{{name}}'?",
"errRequired": "Enter date and name.",
"errDup": "This date is already registered.",
"substituteHint": "Register as substitute holiday",
"cancel": "Cancel",
"saving": "Saving…",
"save": "Save"
} }
} }

View File

@ -1080,7 +1080,20 @@
"rentalSubtotal": "賃料小計", "rentalSubtotal": "賃料小計",
"rentalTotal": "賃料合計 ({{days}}日)", "rentalTotal": "賃料合計 ({{days}}日)",
"deposit": "管理費予託金(別途)", "deposit": "管理費予託金(別途)",
"ruleset": "(ルールセット {{v}})" "ruleset": "(ルールセット {{v}})",
"openDetail": "{{event}} の割当詳細を開く",
"detailTitle": "割当詳細",
"edit": "編集",
"dHall": "ホール",
"dPeriod": "占有期間",
"dStatus": "ステータス",
"dNote": "備考",
"primaryTag": "主ホール",
"dConflict": "競合状況",
"dNoConflict": "重複する割当はありません。",
"dQuote": "自動見積サマリー",
"dQuoteNoPeriod": "占有期間がないため見積を計算できません。",
"dQuoteFail": "見積を取得できませんでした。"
}, },
"ai": { "ai": {
"console": { "console": {
@ -1701,7 +1714,68 @@
"demoFootNote": "承認受信箱から文書を選択すると承認/却下できます。", "demoFootNote": "承認受信箱から文書を選択すると承認/却下できます。",
"demoHint": "承認文書が指定されていません。承認受信箱から開いてください。", "demoHint": "承認文書が指定されていません。承認受信箱から開いてください。",
"commentPlaceholder": "コメント(却下時は必須)を入力", "commentPlaceholder": "コメント(却下時は必須)を入力",
"comment": "コメント" "comment": "コメント",
"boxTitle": "決裁箱",
"compose": "起案",
"composeTitle": "決裁起案",
"resubmitTitle": "却下文書の再申請",
"backToList": "一覧へ",
"tab": {
"PENDING": "決裁待ち",
"DRAFTED": "申請済み",
"REJECTED": "却下",
"DONE": "完了"
},
"empty": {
"PENDING": "決裁する文書がありません",
"DRAFTED": "申請した文書がありません",
"REJECTED": "却下された文書がありません",
"DONE": "完了した文書がありません"
},
"resubmitBtn": "再申請",
"draftSave": "一時保存",
"submitDoc": "申請",
"cancel": "キャンセル",
"saving": "処理中…",
"docType": "文書種別",
"docTypes": {
"GENERAL": "一般",
"DESIGN": "デザイン/図面",
"DOCUMENT": "書類",
"EXPENSE": "費用/支出",
"ETC": "その他"
},
"titleLabel": "タイトル",
"titlePlaceholder": "決裁文書のタイトルを入力してください",
"contentLabel": "内容",
"contentPlaceholder": "決裁文書の内容を入力してください",
"err": {
"titleRequired": "タイトルを入力してください。",
"lineRequired": "決裁ラインを1名以上指定してください。"
},
"createdDraft": "一時保存しました。",
"submittedDone": "申請しました。",
"resubmittedDone": "再申請しました。",
"rejectedReason": "却下理由",
"canResubmitHint": "却下された起案です。修正して再申請できます。",
"lineEditor": {
"empty": "決裁者が指定されていません。決裁者を追加してください。",
"stepNo": "{{n}}段階",
"idPlaceholder": "決裁者ID",
"namePlaceholder": "表示名(任意)",
"approverId": "{{n}}段階の決裁者ID",
"approverName": "{{n}}段階の決裁者名",
"moveUp": "上へ移動",
"moveDown": "下へ移動",
"remove": "決裁者を削除",
"add": "決裁者を追加"
},
"rejectModalTitle": "却下処理",
"approveModalTitle": "承認処理",
"rejectReason": "却下理由",
"rejectReasonPlaceholder": "却下理由を入力してください",
"rejectReasonHint": "却下理由は起案者に通知され、再申請時に表示されます。",
"commentOptional": "コメント(任意)を入力してください"
}, },
"auction": { "auction": {
"boq": { "boq": {
@ -1887,7 +1961,21 @@
"errRequired": "部門名を入力してください。", "errRequired": "部門名を入力してください。",
"loadError": "部門一覧の読み込みに失敗しました。", "loadError": "部門一覧の読み込みに失敗しました。",
"emptyTitle": "登録された部門がありません", "emptyTitle": "登録された部門がありません",
"emptyDesc": "最上位部門を追加してください。" "emptyDesc": "最上位部門を追加してください。",
"searchPlaceholder": "部署名で検索",
"searchNoResult": "一致する部署がありません。",
"treeAria": "部署組織ツリー",
"expand": "展開",
"collapse": "折りたたむ",
"expandAll": "すべて展開",
"collapseAll": "すべて折りたたむ",
"selectHint": "左のツリーから部署を選択してください。",
"colId": "部署ID",
"colParent": "上位部署",
"topLevel": "最上位",
"colChildCount": "下位部署数",
"childCountValue": "{{n}}件",
"errHasChildren": "下位部署があるため削除できません。"
}, },
"errorLog": { "errorLog": {
"title": "エラーログ", "title": "エラーログ",
@ -2000,5 +2088,57 @@
"home": { "home": {
"heroTitle": "AIが設計する展示会の未来" "heroTitle": "AIが設計する展示会の未来"
} }
},
"calendar": {
"prev": "前へ",
"next": "次へ",
"today": "今日",
"yearMonth": "{{y}}年{{m}}月",
"viewSwitch": "ビュー切替",
"view": {
"month": "月",
"week": "週",
"day": "日"
},
"loading": "読み込み中…",
"legend": "凡例",
"dayEmpty": "予定がありません。",
"dayAll": "{{date}} の全予定",
"close": "閉じる"
},
"holiday": {
"title": "祝日管理",
"subtitle": "法定・振替休日の照会と臨時休日の管理。カレンダーに赤色で表示されます。",
"year": "年度",
"yearLabel": "{{y}}年",
"add": "臨時休日を追加",
"created": "祝日を追加しました。",
"deleted": "削除しました。",
"toggled": "休業状態を更新しました。",
"loadError": "祝日を読み込めませんでした。",
"emptyTitle": "祝日がありません",
"emptyDesc": "選択した年度に登録された祝日がありません。",
"listOf": "{{y}}年の祝日",
"count": "休業 {{n}} 日",
"date": "日付",
"weekday": "曜日",
"name": "名称",
"type": "区分",
"status": "休業",
"manage": "管理",
"substitute": "振替休日",
"legal": "法定休日",
"on": "休業",
"off": "通常勤務",
"setOff": "休業解除",
"setOn": "休業設定",
"delete": "削除",
"deleteConfirm": "「{{name}}」を削除しますか?",
"errRequired": "日付と名称を入力してください。",
"errDup": "既に登録済みの日付です。",
"substituteHint": "振替休日として登録",
"cancel": "キャンセル",
"saving": "保存中…",
"save": "保存"
} }
} }

View File

@ -1080,7 +1080,20 @@
"rentalSubtotal": "임대료 소계", "rentalSubtotal": "임대료 소계",
"rentalTotal": "임대료 합계 ({{days}}일)", "rentalTotal": "임대료 합계 ({{days}}일)",
"deposit": "관리비 예치금(별도)", "deposit": "관리비 예치금(별도)",
"ruleset": "(룰셋 {{v}})" "ruleset": "(룰셋 {{v}})",
"openDetail": "{{event}} 배정 상세 열기",
"detailTitle": "배정 상세",
"edit": "수정",
"dHall": "홀",
"dPeriod": "점유 기간",
"dStatus": "상태",
"dNote": "비고",
"primaryTag": "주 배정 홀",
"dConflict": "충돌 여부",
"dNoConflict": "겹치는 배정이 없습니다.",
"dQuote": "자동 견적 요약",
"dQuoteNoPeriod": "점유 기간이 없어 견적을 계산할 수 없습니다.",
"dQuoteFail": "견적을 불러오지 못했습니다."
}, },
"ai": { "ai": {
"console": { "console": {
@ -1701,7 +1714,68 @@
"demoFootNote": "결재 대기함에서 문서를 선택하면 실제 승인/반려가 가능합니다.", "demoFootNote": "결재 대기함에서 문서를 선택하면 실제 승인/반려가 가능합니다.",
"demoHint": "결재 문서가 지정되지 않았습니다. 결재 대기함에서 문서를 열어 주세요.", "demoHint": "결재 문서가 지정되지 않았습니다. 결재 대기함에서 문서를 열어 주세요.",
"commentPlaceholder": "의견(반려 시 필수)을 입력하세요", "commentPlaceholder": "의견(반려 시 필수)을 입력하세요",
"comment": "의견" "comment": "의견",
"boxTitle": "결재함",
"compose": "기안",
"composeTitle": "결재 기안",
"resubmitTitle": "반려 문서 재상신",
"backToList": "목록",
"tab": {
"PENDING": "결재할 문서",
"DRAFTED": "상신한 문서",
"REJECTED": "반려",
"DONE": "완료"
},
"empty": {
"PENDING": "결재할 문서가 없습니다",
"DRAFTED": "상신한 문서가 없습니다",
"REJECTED": "반려된 문서가 없습니다",
"DONE": "완료된 문서가 없습니다"
},
"resubmitBtn": "재상신",
"draftSave": "임시저장",
"submitDoc": "상신",
"cancel": "취소",
"saving": "처리 중…",
"docType": "문서유형",
"docTypes": {
"GENERAL": "일반",
"DESIGN": "디자인/도면",
"DOCUMENT": "서류",
"EXPENSE": "비용/지출",
"ETC": "기타"
},
"titleLabel": "제목",
"titlePlaceholder": "결재 문서 제목을 입력하세요",
"contentLabel": "내용",
"contentPlaceholder": "결재 문서 내용을 입력하세요",
"err": {
"titleRequired": "제목을 입력하세요.",
"lineRequired": "결재선을 1명 이상 지정하세요."
},
"createdDraft": "임시저장되었습니다.",
"submittedDone": "상신되었습니다.",
"resubmittedDone": "재상신되었습니다.",
"rejectedReason": "반려 사유",
"canResubmitHint": "반려된 기안입니다. 수정 후 재상신할 수 있습니다.",
"lineEditor": {
"empty": "지정된 결재자가 없습니다. 결재자를 추가하세요.",
"stepNo": "{{n}}단계",
"idPlaceholder": "결재자 ID",
"namePlaceholder": "표시명(선택)",
"approverId": "{{n}}단계 결재자 ID",
"approverName": "{{n}}단계 결재자 이름",
"moveUp": "위로 이동",
"moveDown": "아래로 이동",
"remove": "결재자 삭제",
"add": "결재자 추가"
},
"rejectModalTitle": "반려 처리",
"approveModalTitle": "승인 처리",
"rejectReason": "반려 사유",
"rejectReasonPlaceholder": "반려 사유를 입력하세요",
"rejectReasonHint": "반려 사유는 기안자에게 전달되며 재상신 시 표시됩니다.",
"commentOptional": "의견(선택)을 입력하세요"
}, },
"auction": { "auction": {
"boq": { "boq": {
@ -1887,7 +1961,21 @@
"errRequired": "부서명을 입력하세요.", "errRequired": "부서명을 입력하세요.",
"loadError": "부서 목록을 불러오지 못했습니다.", "loadError": "부서 목록을 불러오지 못했습니다.",
"emptyTitle": "등록된 부서가 없습니다", "emptyTitle": "등록된 부서가 없습니다",
"emptyDesc": "최상위 부서를 추가해 주세요." "emptyDesc": "최상위 부서를 추가해 주세요.",
"searchPlaceholder": "부서명 검색",
"searchNoResult": "검색 결과가 없습니다.",
"treeAria": "부서 조직도 트리",
"expand": "펼치기",
"collapse": "접기",
"expandAll": "모두 펼치기",
"collapseAll": "모두 접기",
"selectHint": "좌측에서 부서를 선택하세요.",
"colId": "부서 ID",
"colParent": "상위 부서",
"topLevel": "최상위",
"colChildCount": "하위 부서 수",
"childCountValue": "{{n}}개",
"errHasChildren": "하위 부서가 있어 삭제할 수 없습니다."
}, },
"errorLog": { "errorLog": {
"title": "오류 로그", "title": "오류 로그",
@ -2000,5 +2088,57 @@
"home": { "home": {
"heroTitle": "AI가 설계하는 전시의 미래" "heroTitle": "AI가 설계하는 전시의 미래"
} }
},
"calendar": {
"prev": "이전",
"next": "다음",
"today": "오늘",
"yearMonth": "{{y}}년 {{m}}월",
"viewSwitch": "뷰 전환",
"view": {
"month": "월",
"week": "주",
"day": "일"
},
"loading": "불러오는 중…",
"legend": "범례",
"dayEmpty": "등록된 일정이 없습니다.",
"dayAll": "{{date}} 전체 일정",
"close": "닫기"
},
"holiday": {
"title": "공휴일 관리",
"subtitle": "법정공휴일·대체공휴일 조회 및 임시공휴일 관리. 달력에 붉은색으로 표시됩니다.",
"year": "연도",
"yearLabel": "{{y}}년",
"add": "임시공휴일 추가",
"created": "공휴일이 추가되었습니다.",
"deleted": "삭제되었습니다.",
"toggled": "휴무 여부가 변경되었습니다.",
"loadError": "공휴일을 불러오지 못했습니다.",
"emptyTitle": "공휴일이 없습니다",
"emptyDesc": "선택한 연도에 등록된 공휴일이 없습니다.",
"listOf": "{{y}}년 공휴일",
"count": "휴무 {{n}}일",
"date": "날짜",
"weekday": "요일",
"name": "명칭",
"type": "구분",
"status": "휴무",
"manage": "관리",
"substitute": "대체공휴일",
"legal": "법정공휴일",
"on": "휴무",
"off": "정상근무",
"setOff": "임시휴무 해제",
"setOn": "임시휴무 설정",
"delete": "삭제",
"deleteConfirm": "'{{name}}'을(를) 삭제할까요?",
"errRequired": "날짜와 명칭을 입력해 주세요.",
"errDup": "이미 등록된 날짜입니다.",
"substituteHint": "대체공휴일로 등록",
"cancel": "취소",
"saving": "저장 중…",
"save": "저장"
} }
} }

View File

@ -1080,7 +1080,20 @@
"rentalSubtotal": "租金小计", "rentalSubtotal": "租金小计",
"rentalTotal": "租金合计 ({{days}}天)", "rentalTotal": "租金合计 ({{days}}天)",
"deposit": "管理费押金(另计)", "deposit": "管理费押金(另计)",
"ruleset": "(规则集 {{v}})" "ruleset": "(规则集 {{v}})",
"openDetail": "打开 {{event}} 分配详情",
"detailTitle": "分配详情",
"edit": "修改",
"dHall": "展厅",
"dPeriod": "占用期间",
"dStatus": "状态",
"dNote": "备注",
"primaryTag": "主展厅",
"dConflict": "冲突情况",
"dNoConflict": "没有重叠的分配。",
"dQuote": "自动报价摘要",
"dQuoteNoPeriod": "无占用期间,无法计算报价。",
"dQuoteFail": "无法加载报价。"
}, },
"ai": { "ai": {
"console": { "console": {
@ -1701,7 +1714,68 @@
"demoFootNote": "从审批收件箱选择文档即可批准或驳回。", "demoFootNote": "从审批收件箱选择文档即可批准或驳回。",
"demoHint": "未指定审批文档,请从审批收件箱打开。", "demoHint": "未指定审批文档,请从审批收件箱打开。",
"commentPlaceholder": "输入意见(驳回时必填)", "commentPlaceholder": "输入意见(驳回时必填)",
"comment": "意见" "comment": "意见",
"boxTitle": "审批箱",
"compose": "拟稿",
"composeTitle": "审批拟稿",
"resubmitTitle": "退回文件再上报",
"backToList": "返回列表",
"tab": {
"PENDING": "待审批",
"DRAFTED": "已上报",
"REJECTED": "已退回",
"DONE": "已完成"
},
"empty": {
"PENDING": "没有待审批的文件",
"DRAFTED": "没有已上报的文件",
"REJECTED": "没有被退回的文件",
"DONE": "没有已完成的文件"
},
"resubmitBtn": "再上报",
"draftSave": "临时保存",
"submitDoc": "上报",
"cancel": "取消",
"saving": "处理中…",
"docType": "文件类型",
"docTypes": {
"GENERAL": "一般",
"DESIGN": "设计/图纸",
"DOCUMENT": "文件",
"EXPENSE": "费用/支出",
"ETC": "其他"
},
"titleLabel": "标题",
"titlePlaceholder": "请输入审批文件标题",
"contentLabel": "内容",
"contentPlaceholder": "请输入审批文件内容",
"err": {
"titleRequired": "请输入标题。",
"lineRequired": "请至少指定一名审批人。"
},
"createdDraft": "已临时保存。",
"submittedDone": "已上报审批。",
"resubmittedDone": "已再次上报。",
"rejectedReason": "退回原因",
"canResubmitHint": "该拟稿已被退回。可修改后再上报。",
"lineEditor": {
"empty": "尚未指定审批人。请添加审批人。",
"stepNo": "第{{n}}级",
"idPlaceholder": "审批人ID",
"namePlaceholder": "显示名(可选)",
"approverId": "第{{n}}级审批人ID",
"approverName": "第{{n}}级审批人姓名",
"moveUp": "上移",
"moveDown": "下移",
"remove": "删除审批人",
"add": "添加审批人"
},
"rejectModalTitle": "退回处理",
"approveModalTitle": "批准处理",
"rejectReason": "退回原因",
"rejectReasonPlaceholder": "请输入退回原因",
"rejectReasonHint": "退回原因将发送给拟稿人,并在再上报时显示。",
"commentOptional": "请输入意见(可选)"
}, },
"auction": { "auction": {
"boq": { "boq": {
@ -1887,7 +1961,21 @@
"errRequired": "请输入部门名称。", "errRequired": "请输入部门名称。",
"loadError": "无法加载部门列表。", "loadError": "无法加载部门列表。",
"emptyTitle": "尚无已登记部门", "emptyTitle": "尚无已登记部门",
"emptyDesc": "请添加顶级部门。" "emptyDesc": "请添加顶级部门。",
"searchPlaceholder": "搜索部门",
"searchNoResult": "无匹配部门。",
"treeAria": "部门组织树",
"expand": "展开",
"collapse": "折叠",
"expandAll": "全部展开",
"collapseAll": "全部折叠",
"selectHint": "请从左侧选择一个部门。",
"colId": "部门ID",
"colParent": "上级部门",
"topLevel": "最高级",
"colChildCount": "下级部门数",
"childCountValue": "{{n}}个",
"errHasChildren": "存在下级部门,无法删除。"
}, },
"errorLog": { "errorLog": {
"title": "错误日志", "title": "错误日志",
@ -2000,5 +2088,57 @@
"home": { "home": {
"heroTitle": "AI 设计的展会未来" "heroTitle": "AI 设计的展会未来"
} }
},
"calendar": {
"prev": "上一个",
"next": "下一个",
"today": "今天",
"yearMonth": "{{y}}年{{m}}月",
"viewSwitch": "切换视图",
"view": {
"month": "月",
"week": "周",
"day": "日"
},
"loading": "加载中…",
"legend": "图例",
"dayEmpty": "没有日程。",
"dayAll": "{{date}} 全部日程",
"close": "关闭"
},
"holiday": {
"title": "节假日管理",
"subtitle": "查看法定/替代节假日并管理临时节假日。在日历中以红色显示。",
"year": "年度",
"yearLabel": "{{y}}年",
"add": "添加临时节假日",
"created": "已添加节假日。",
"deleted": "已删除。",
"toggled": "休息状态已更新。",
"loadError": "无法加载节假日。",
"emptyTitle": "无节假日",
"emptyDesc": "所选年度没有已登记的节假日。",
"listOf": "{{y}}年节假日",
"count": "休息 {{n}} 天",
"date": "日期",
"weekday": "星期",
"name": "名称",
"type": "类型",
"status": "休息",
"manage": "管理",
"substitute": "替代节假日",
"legal": "法定节假日",
"on": "休息",
"off": "正常上班",
"setOff": "取消休息",
"setOn": "设为休息",
"delete": "删除",
"deleteConfirm": "删除“{{name}}”?",
"errRequired": "请输入日期和名称。",
"errDup": "该日期已登记。",
"substituteHint": "登记为替代节假日",
"cancel": "取消",
"saving": "保存中…",
"save": "保存"
} }
} }

View File

@ -1,47 +1,187 @@
/* /*
* W12 ( ) . * W12 () . WISE (UIWS system/dept·org) .
* 정본: GET/POST /api/admin/depts · DELETE /{id} (DeptController). (children ), upsert . * : 부서 ( ·/·/ · ··).
* : 선택 (·ID···· ) + CRUD( //, ).
* (): GET/POST /api/admin/depts · DELETE /{id} (DeptController). (children ), upsert .
* ( + ).
*/ */
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons'; import {
IconPlus, IconUsers, IconSearch, IconChevronRight, IconChevronDown,
} from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { deptApi, type DeptNode, type DeptSaveBody } from './systemBasicsApi'; import { deptApi, type DeptNode, type DeptSaveBody } from './systemBasicsApi';
import './admin-modules.css'; import './admin-modules.css';
import './dept-admin.css';
interface FlatDept { node: DeptNode; depth: number } // ── 트리 유틸 ─────────────────────────────────────────────
/** id → 노드 인덱스(상세·경로·부모명 조회). */
function flatten(nodes: DeptNode[], depth: number, out: FlatDept[]) { function indexNodes(nodes: DeptNode[]): Map<string, DeptNode> {
for (const n of nodes) { const map = new Map<string, DeptNode>();
out.push({ node: n, depth }); const walk = (ns: DeptNode[]) => {
if (n.children && n.children.length) flatten(n.children, depth + 1, out); for (const n of ns) { map.set(n.id, n); walk(n.children ?? []); }
} };
walk(nodes);
return map;
} }
/** 전위순회 평면화 — 부모 후보 select·정렬값 산출. */
function flattenNodes(nodes: DeptNode[], acc: DeptNode[] = []): DeptNode[] {
for (const n of nodes) { acc.push(n); flattenNodes(n.children ?? [], acc); }
return acc;
}
/** 모든 노드 id(전체 펼침용). */
function collectIds(nodes: DeptNode[], acc: string[] = []): string[] {
for (const n of nodes) { acc.push(n.id); collectIds(n.children ?? [], acc); }
return acc;
}
interface FlatRow { node: DeptNode; depth: number; hasChildren: boolean }
/** 펼침 상태를 반영해 가시 행만 평탄화. */
function flattenVisible(nodes: DeptNode[], expanded: Set<string>, depth = 0, acc: FlatRow[] = []): FlatRow[] {
for (const node of nodes) {
const hasChildren = (node.children?.length ?? 0) > 0;
acc.push({ node, depth, hasChildren });
if (hasChildren && expanded.has(node.id)) flattenVisible(node.children!, expanded, depth + 1, acc);
}
return acc;
}
/** 부서명 매칭 id 집합 + 조상(자동 펼침용). */
function computeMatches(nodes: DeptNode[], q: string): { matched: Set<string>; ancestors: Set<string> } {
const matched = new Set<string>();
const ancestors = new Set<string>();
const lc = q.toLowerCase();
const walk = (node: DeptNode, chain: string[]) => {
if (node.deptName.toLowerCase().includes(lc)) {
matched.add(node.id);
chain.forEach((a) => ancestors.add(a));
}
(node.children ?? []).forEach((c) => walk(c, [...chain, node.id]));
};
nodes.forEach((n) => walk(n, []));
return { matched, ancestors };
}
/** 선택 부서의 전체 경로(루트 부서). */
function deptPath(id: string, index: Map<string, DeptNode>): string {
const parts: string[] = [];
let cur: DeptNode | undefined = index.get(id);
const guard = new Set<string>();
while (cur && !guard.has(cur.id)) {
guard.add(cur.id);
parts.unshift(cur.deptName);
cur = cur.parentId ? index.get(cur.parentId) : undefined;
}
return parts.join(' ');
}
/** 자손 id 집합(부모 후보 순환 제외용). */
function descendantsOf(node: DeptNode): Set<string> {
const set = new Set<string>();
const walk = (n: DeptNode) => (n.children ?? []).forEach((c) => { set.add(c.id); walk(c); });
walk(node);
return set;
}
/** 검색어 하이라이트. */
function Highlight({ text, term }: { text: string; term: string }): ReactNode {
if (!term) return text;
const idx = text.toLowerCase().indexOf(term.toLowerCase());
if (idx < 0) return text;
return (
<>
{text.slice(0, idx)}
<mark className="kx-dept-mark">{text.slice(idx, idx + term.length)}</mark>
{text.slice(idx + term.length)}
</>
);
}
// ── 메인 ──────────────────────────────────────────────────
type ModalState =
| { mode: 'create'; parentId: string | null }
| { mode: 'edit'; node: DeptNode }
| null;
export function DeptAdminPage() { export function DeptAdminPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { show, node: toast } = useToast(); const { show, node: toast } = useToast();
const q = useQuery({ queryKey: ['admin-depts'], queryFn: deptApi.tree, retry: false }); const q = useQuery({ queryKey: ['admin-depts'], queryFn: deptApi.tree, retry: false });
const [editing, setEditing] = useState<{ node: DeptNode | null; parentId: string | null } | undefined>(undefined);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [modal, setModal] = useState<ModalState>(null);
const bootRef = useRef(false);
const tree = q.data ?? []; const tree = q.data ?? [];
const flat = useMemo(() => { const o: FlatDept[] = []; flatten(tree, 0, o); return o; }, [tree]); const index = useMemo(() => indexNodes(tree), [tree]);
const flat = useMemo(() => flattenNodes(tree), [tree]);
// 최초 로드 시 전체 펼침 + 첫 부서 선택.
useEffect(() => {
if (!q.data || bootRef.current) return;
bootRef.current = true;
setExpanded(new Set(collectIds(q.data)));
if (q.data.length > 0) setSelectedId(q.data[0].id);
}, [q.data]);
// 검색 디바운스 250ms.
useEffect(() => {
const id = window.setTimeout(() => setDebouncedQ(query.trim()), 250);
return () => window.clearTimeout(id);
}, [query]);
const searching = debouncedQ.length > 0;
const matches = useMemo(() => (searching ? computeMatches(tree, debouncedQ) : null), [searching, tree, debouncedQ]);
useEffect(() => {
if (!matches) return;
setExpanded((prev) => {
const next = new Set(prev);
matches.ancestors.forEach((a) => next.add(a));
matches.matched.forEach((d) => next.add(d));
return next;
});
}, [matches]);
const rows = useMemo(() => flattenVisible(tree, expanded), [tree, expanded]);
const selectedNode = selectedId ? index.get(selectedId) ?? null : null;
const nextSort = useMemo(() => flat.reduce((m, d) => Math.max(m, d.sortOrder ?? 0), 0) + 1, [flat]);
const toggle = (id: string) => setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const saveM = useMutation({ const saveM = useMutation({
mutationFn: (body: DeptSaveBody) => deptApi.save(body), mutationFn: (body: DeptSaveBody) => deptApi.save(body),
onSuccess: () => { show(t('dept.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-depts'] }); }, onSuccess: () => { show(t('dept.saved')); setModal(null); qc.invalidateQueries({ queryKey: ['admin-depts'] }); },
onError: (e) => show(errMessage(e)), onError: (e) => show(errMessage(e)),
}); });
const delM = useMutation({ const delM = useMutation({
mutationFn: (id: string) => deptApi.remove(id), mutationFn: (id: string) => deptApi.remove(id),
onSuccess: () => { show(t('dept.deleted')); qc.invalidateQueries({ queryKey: ['admin-depts'] }); }, onSuccess: (_r, id) => {
show(t('dept.deleted'));
if (selectedId === id) setSelectedId(index.get(id)?.parentId ?? null);
qc.invalidateQueries({ queryKey: ['admin-depts'] });
},
onError: (e) => show(errMessage(e)), onError: (e) => show(errMessage(e)),
}); });
const onDelete = (node: DeptNode) => {
if ((node.children?.length ?? 0) > 0) { show(t('dept.errHasChildren')); return; }
if (window.confirm(t('dept.deleteConfirm', { name: node.deptName }))) delM.mutate(node.id);
};
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
@ -49,79 +189,206 @@ export function DeptAdminPage() {
<h1 className="kx-adm__title">{t('dept.title')}</h1> <h1 className="kx-adm__title">{t('dept.title')}</h1>
<p className="kx-adm__subtitle">{t('dept.subtitle')}</p> <p className="kx-adm__subtitle">{t('dept.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing({ node: null, parentId: null })}> <div className="kx-dept-headActions">
<Button variant="ghost" onClick={() => setExpanded(new Set(collectIds(tree)))}>{t('dept.expandAll')}</Button>
<Button variant="ghost" onClick={() => setExpanded(new Set())}>{t('dept.collapseAll')}</Button>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setModal({ mode: 'create', parentId: null })}>
{t('dept.addRoot')} {t('dept.addRoot')}
</Button> </Button>
</div>
</header> </header>
{q.isLoading && <Skeleton height={320} radius={12} />} {q.isLoading && <Skeleton height={360} radius={12} />}
{q.isError && !q.isLoading && <ErrorState message={t('dept.loadError')} onRetry={() => q.refetch()} />} {q.isError && !q.isLoading && <ErrorState message={t('dept.loadError')} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.isError && ( {!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card"> <>
{flat.length === 0 ? ( <div className="kx-dept-toolbar">
<EmptyState title={t('dept.emptyTitle')} description={t('dept.emptyDesc')} /> <span className="kx-dept-search">
) : ( <IconSearch size={16} className="kx-dept-search__icon" />
<ul className="kx-adm-tree"> <input
{flat.map(({ node, depth }) => ( className="kx-dept-search__input"
<li key={node.id} className="kx-adm-tree__row" style={{ paddingLeft: 12 + depth * 20 }}> type="search"
<span className="kx-adm-tree__name"> value={query}
{node.deptName} placeholder={t('dept.searchPlaceholder')}
{node.useYn === 'N' && <span className="kx-adm-muted"> · {t('sysadmin.noUse')}</span>} onChange={(e) => setQuery(e.target.value)}
aria-label={t('dept.searchPlaceholder')}
/>
</span> </span>
<span className="kx-adm-tree__actions"> </div>
<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> {flat.length === 0 ? (
<section className="kx-card">
<EmptyState title={t('dept.emptyTitle')} description={t('dept.emptyDesc')} />
</section>
) : (
<div className="kx-dept-body">
{/* 좌 — 부서 트리 */}
<aside className="kx-dept-pane kx-dept-tree-pane">
<div className="kx-dept-tree" role="tree" aria-label={t('dept.treeAria')}>
{rows.length === 0 ? (
<p className="kx-dept-tree__empty">{t('dept.searchNoResult')}</p>
) : rows.map(({ node, depth, hasChildren }) => {
const selected = node.id === selectedId;
const open = expanded.has(node.id);
return (
<div
key={node.id}
role="treeitem"
aria-level={depth + 1}
aria-expanded={hasChildren ? open : undefined}
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`kx-dept-tree__row${selected ? ' is-selected' : ''}`}
style={{ paddingLeft: depth * 18 + 8 }}
onClick={() => { setSelectedId(node.id); setQuery(''); }}
>
{hasChildren ? (
<button
type="button"
className="kx-dept-tree__toggle"
aria-label={open ? t('dept.collapse') : t('dept.expand')}
tabIndex={-1}
onClick={(e) => { e.stopPropagation(); toggle(node.id); }}
>
{open ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
</button>
) : (
<span className="kx-dept-tree__toggle kx-dept-tree__toggle--empty" aria-hidden="true" />
)}
<IconUsers size={16} className="kx-dept-tree__icon" />
<span className="kx-dept-tree__name"><Highlight text={node.deptName} term={debouncedQ} /></span>
<span className="kx-dept-tree__sort">#{node.sortOrder ?? 0}</span>
{node.useYn === 'N' && <span className="kx-dept-tree__off">{t('sysadmin.noUse')}</span>}
</div>
);
})}
</div>
</aside>
{/* 우 — 선택 부서 상세/편집 */}
<section className="kx-dept-pane kx-dept-detail-pane" aria-live="polite">
{!selectedNode ? (
<div className="kx-dept-detail__empty">
<IconUsers size={40} />
<p>{t('dept.selectHint')}</p>
</div>
) : (
<>
<div className="kx-dept-detail__head">
<div className="kx-dept-detail__headMain">
<h2 className="kx-dept-detail__title">{selectedNode.deptName}</h2>
<p className="kx-dept-detail__path">{deptPath(selectedNode.id, index)}</p>
</div>
<div className="kx-dept-detail__actions">
<Button variant="ghost" leadingIcon={<IconPlus size={14} />}
onClick={() => setModal({ mode: 'create', parentId: selectedNode.id })}>
{t('dept.addChild')}
</Button>
<Button variant="ghost" onClick={() => setModal({ mode: 'edit', node: selectedNode })}>
{t('sysadmin.edit')}
</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending} <Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('dept.deleteConfirm', { name: node.deptName }))) delM.mutate(node.id); }}> onClick={() => onDelete(selectedNode)}>
{t('sysadmin.delete')} {t('sysadmin.delete')}
</Button> </Button>
</span> </div>
</li> </div>
))} <dl className="kx-dept-detail__list">
</ul> <div className="kx-dept-detail__item">
<dt>{t('dept.name')}</dt><dd>{selectedNode.deptName}</dd>
</div>
<div className="kx-dept-detail__item">
<dt>{t('dept.colId')}</dt><dd><code className="kx-dept-code">{selectedNode.id}</code></dd>
</div>
<div className="kx-dept-detail__item">
<dt>{t('dept.colParent')}</dt>
<dd>{selectedNode.parentId
? `${index.get(selectedNode.parentId)?.deptName ?? selectedNode.parentId}`
: t('dept.topLevel')}</dd>
</div>
<div className="kx-dept-detail__item">
<dt>{t('sysadmin.sortOrder')}</dt><dd>#{selectedNode.sortOrder ?? 0}</dd>
</div>
<div className="kx-dept-detail__item">
<dt>{t('sysadmin.useYn')}</dt>
<dd>{selectedNode.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')}</dd>
</div>
<div className="kx-dept-detail__item">
<dt>{t('dept.colChildCount')}</dt>
<dd>{t('dept.childCountValue', { n: selectedNode.children?.length ?? 0 })}</dd>
</div>
</dl>
</>
)} )}
</section> </section>
</div>
)}
</>
)} )}
{editing !== undefined && ( {modal && (
<DeptModal node={editing.node} parentId={editing.parentId} saving={saveM.isPending} <DeptModal
onClose={() => setEditing(undefined)} onSubmit={(b) => saveM.mutate(b)} /> state={modal}
allDepts={flat}
nextSort={nextSort}
saving={saveM.isPending}
onClose={() => setModal(null)}
onSubmit={(b) => saveM.mutate(b)}
/>
)} )}
{toast} {toast}
</div> </div>
); );
} }
function DeptModal({ node, parentId, saving, onClose, onSubmit }: { // ── 추가/수정 모달(상위 부서 선택 포함) ──
node: DeptNode | null; parentId: string | null; saving: boolean; function DeptModal({ state, allDepts, nextSort, saving, onClose, onSubmit }: {
onClose: () => void; onSubmit: (b: DeptSaveBody) => void; state: NonNullable<ModalState>;
allDepts: DeptNode[];
nextSort: number;
saving: boolean;
onClose: () => void;
onSubmit: (b: DeptSaveBody) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isNew = node === null; const isEdit = state.mode === 'edit';
const node = isEdit ? state.node : null;
const [deptName, setName] = useState(node?.deptName ?? ''); const [deptName, setName] = useState(node?.deptName ?? '');
const [sortOrder, setSort] = useState(node?.sortOrder ?? 0); const [parentId, setParentId] = useState<string | null>(isEdit ? node!.parentId : state.parentId);
const [sortOrder, setSort] = useState(node?.sortOrder ?? nextSort);
const [useYn, setUseYn] = useState(node?.useYn ?? 'Y'); const [useYn, setUseYn] = useState(node?.useYn ?? 'Y');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function submit(e: React.FormEvent) { // 상위 후보: 수정 시 자기 자신 + 자손 배제(순환 방지).
const parentCandidates = useMemo(() => {
if (!isEdit) return allDepts;
const excluded = descendantsOf(node!);
excluded.add(node!.id);
return allDepts.filter((d) => !excluded.has(d.id));
}, [isEdit, allDepts, node]);
function submit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!deptName.trim()) { setError(t('dept.errRequired')); return; } if (!deptName.trim()) { setError(t('dept.errRequired')); return; }
onSubmit({ onSubmit({ id: node?.id ?? null, deptName: deptName.trim(), parentId, sortOrder, useYn });
id: node?.id ?? null,
deptName: deptName.trim(),
parentId: node ? node.parentId : parentId,
sortOrder,
useYn,
});
} }
return ( return (
<div className="kx-adm-modal" role="dialog" aria-modal="true"> <div className="kx-adm-modal" role="dialog" aria-modal="true">
<div className="kx-adm-modal__backdrop" onClick={onClose} /> <div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}> <form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 className="kx-adm-modal__title">{isNew ? t('dept.add') : t('dept.edit')}</h2> <h2 className="kx-adm-modal__title">{isEdit ? t('dept.edit') : (state.parentId ? t('dept.addChild') : t('dept.add'))}</h2>
<div className="kx-adm-modal__grid"> <div className="kx-adm-modal__grid">
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('dept.colParent')}</span>
<select className="kx-adm-field__select" value={parentId ?? ''} onChange={(e) => setParentId(e.target.value || null)}>
<option value="">{t('dept.topLevel')}</option>
{parentCandidates.map((d) => (
<option key={d.id} value={d.id}>{d.deptName}</option>
))}
</select>
</label>
<label className="kx-adm-field kx-adm-field--full"> <label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('dept.name')}</span> <span className="kx-adm-field__label">{t('dept.name')}</span>
<input className="kx-adm-field__input" value={deptName} onChange={(e) => setName(e.target.value)} /> <input className="kx-adm-field__input" value={deptName} onChange={(e) => setName(e.target.value)} />

View File

@ -0,0 +1,271 @@
/*
* (SCR-A · §5B). HolidayController .
* 정본: GET /api/admin/holidays?year=(ADMIN) · POST/DELETE/PATCH /api/admin/holidays (ADMIN).
* UI: 연도 + (··· · ·) + .
* ·(2024~2050) V41 · . .
*/
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 { holidayApi, type HolidayCreateBody } from './holidayApi';
import './admin-modules.css';
/** 데이터 미존재 시 폴백 연도 목록(V41 시드 범위). */
function fallbackYears(): number[] {
const arr: number[] = [];
for (let y = 2050; y >= 2024; y--) arr.push(y);
return arr;
}
function weekdayIndex(ymd: string): number {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1).getDay();
}
export function HolidayAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const yearsQ = useQuery({ queryKey: ['admin-holiday-years'], queryFn: holidayApi.years, retry: false });
const years = yearsQ.data && yearsQ.data.length > 0 ? yearsQ.data : fallbackYears();
const [year, setYear] = useState<number>(new Date().getFullYear());
const [adding, setAdding] = useState(false);
const listQ = useQuery({
queryKey: ['admin-holidays', year],
queryFn: () => holidayApi.adminList(year),
retry: false,
});
const list = listQ.data ?? [];
const createM = useMutation({
mutationFn: (body: HolidayCreateBody) => holidayApi.create(body),
onSuccess: () => {
show(t('holiday.created'));
setAdding(false);
qc.invalidateQueries({ queryKey: ['admin-holidays'] });
qc.invalidateQueries({ queryKey: ['admin-holiday-years'] });
},
onError: (e) => show(errMessage(e)),
});
const delM = useMutation({
mutationFn: (ymd: string) => holidayApi.remove(ymd),
onSuccess: () => {
show(t('holiday.deleted'));
qc.invalidateQueries({ queryKey: ['admin-holidays'] });
},
onError: (e) => show(errMessage(e)),
});
const toggleM = useMutation({
mutationFn: ({ ymd, isHoliday }: { ymd: string; isHoliday: boolean }) =>
holidayApi.toggle(ymd, isHoliday),
onSuccess: () => {
show(t('holiday.toggled'));
qc.invalidateQueries({ queryKey: ['admin-holidays'] });
},
onError: (e) => show(errMessage(e)),
});
const count = useMemo(() => list.filter((h) => h.isHoliday).length, [list]);
return (
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title">{t('holiday.title')}</h1>
<p className="kx-adm__subtitle">{t('holiday.subtitle')}</p>
</div>
<div className="kx-adm-tbl__actions">
<label className="kx-adm-field" style={{ minWidth: 120 }}>
<span className="kx-adm-field__label">{t('holiday.year')}</span>
<select
className="kx-adm-field__select"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
>
{years.map((y) => (
<option key={y} value={y}>
{t('holiday.yearLabel', { y })}
</option>
))}
</select>
</label>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setAdding(true)}>
{t('holiday.add')}
</Button>
</div>
</header>
{listQ.isLoading && <Skeleton height={320} radius={12} />}
{listQ.isError && !listQ.isLoading && (
<ErrorState message={t('holiday.loadError')} onRetry={() => listQ.refetch()} />
)}
{!listQ.isLoading && !listQ.isError && (
list.length === 0 ? (
<EmptyState title={t('holiday.emptyTitle')} description={t('holiday.emptyDesc')} />
) : (
<section className="kx-card">
<div className="kx-adm-codes__head">
<h2>{t('holiday.listOf', { y: year })}</h2>
<p className="kx-adm-muted">{t('holiday.count', { n: count })}</p>
</div>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl">
<thead>
<tr>
<th>{t('holiday.date')}</th>
<th>{t('holiday.weekday')}</th>
<th>{t('holiday.name')}</th>
<th>{t('holiday.type')}</th>
<th>{t('holiday.status')}</th>
<th style={{ textAlign: 'right' }}>{t('holiday.manage')}</th>
</tr>
</thead>
<tbody>
{list.map((h) => {
const dow = weekdayIndex(h.ymd);
const isSun = dow === 0;
return (
<tr key={h.ymd}>
<td className="kx-adm-mono" style={isSun ? { color: 'var(--color-error)' } : undefined}>
{h.ymd}
</td>
<td style={isSun ? { color: 'var(--color-error)' } : undefined}>
{t(`common.weekday.${dow}`)}
</td>
<td className="kx-adm-tbl__strong" style={{ color: 'var(--color-error)' }}>
{h.name}
</td>
<td>
{h.isSubstitute ? (
<span className="kx-adm-pill kx-adm-pill--neutral">{t('holiday.substitute')}</span>
) : (
<span className="kx-adm-muted">{t('holiday.legal')}</span>
)}
</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${h.isHoliday ? 'success' : 'neutral'}`}>
{h.isHoliday ? t('holiday.on') : t('holiday.off')}
</span>
</td>
<td>
<div className="kx-adm-tbl__actions">
<Button
variant="ghost"
disabled={toggleM.isPending}
onClick={() => toggleM.mutate({ ymd: h.ymd, isHoliday: !h.isHoliday })}
>
{h.isHoliday ? t('holiday.setOff') : t('holiday.setOn')}
</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delM.isPending}
onClick={() => {
if (window.confirm(t('holiday.deleteConfirm', { name: h.name })))
delM.mutate(h.ymd);
}}
>
{t('holiday.delete')}
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)
)}
{adding && (
<HolidayAddModal
defaultYear={year}
existing={list.map((h) => h.ymd)}
saving={createM.isPending}
onClose={() => setAdding(false)}
onSubmit={(body) => createM.mutate(body)}
/>
)}
{toast}
</div>
);
}
function HolidayAddModal({
defaultYear,
existing,
saving,
onClose,
onSubmit,
}: {
defaultYear: number;
existing: string[];
saving: boolean;
onClose: () => void;
onSubmit: (body: HolidayCreateBody) => void;
}) {
const { t } = useTranslation();
const [ymd, setYmd] = useState(`${defaultYear}-01-01`);
const [name, setName] = useState('');
const [isSubstitute, setIsSubstitute] = useState(false);
const [error, setError] = useState<string | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd) || !name.trim()) {
setError(t('holiday.errRequired'));
return;
}
if (existing.includes(ymd)) {
setError(t('holiday.errDup'));
return;
}
onSubmit({ ymd, name: name.trim(), isSubstitute });
}
return (
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="holiday-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="holiday-modal-title" className="kx-adm-modal__title">{t('holiday.add')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('holiday.date')}</span>
<input
className="kx-adm-field__input"
type="date"
value={ymd}
onChange={(e) => setYmd(e.target.value)}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('holiday.name')}</span>
<input className="kx-adm-field__input" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="kx-adm-field kx-adm-field--full" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={isSubstitute}
onChange={(e) => setIsSubstitute(e.target.checked)}
/>
<span className="kx-adm-field__label" style={{ margin: 0 }}>{t('holiday.substituteHint')}</span>
</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('holiday.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('holiday.saving') : t('holiday.save')}</Button>
</div>
</form>
</div>
);
}

View File

@ -106,21 +106,22 @@
.kx-adm-audit { width: 100%; border-collapse: collapse; } .kx-adm-audit { width: 100%; border-collapse: collapse; }
.kx-adm-audit th, .kx-adm-audit th,
.kx-adm-audit td { .kx-adm-audit td {
padding: var(--space-3) var(--space-4); padding: 10px 12px;
text-align: left; text-align: left;
font-size: var(--fs-body); font-size: var(--fs-body);
vertical-align: top; vertical-align: top;
} }
.kx-adm-audit thead th { .kx-adm-audit thead th {
background: var(--color-neutral-100); background: var(--color-neutral-050);
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 700; font-weight: 600;
color: var(--color-neutral-500); color: var(--color-neutral-500);
text-transform: uppercase; letter-spacing: 0.02em;
letter-spacing: 0.03em; border-bottom: 2px solid var(--color-neutral-200);
border-bottom: var(--border-card); white-space: nowrap;
vertical-align: bottom;
} }
.kx-adm-audit tbody tr { border-bottom: 1px solid var(--color-neutral-100); } .kx-adm-audit tbody tr { border-bottom: 1px solid var(--color-neutral-200); }
.kx-adm-audit tbody tr:hover { background: var(--color-primary-050); } .kx-adm-audit tbody tr:hover { background: var(--color-primary-050); }
.kx-adm-actor { display: flex; align-items: center; gap: var(--space-2); } .kx-adm-actor { display: flex; align-items: center; gap: var(--space-2); }
@ -318,15 +319,18 @@
} }
.kx-adm-ruleset__ehead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); } .kx-adm-ruleset__ehead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-rules { width: 100%; border-collapse: collapse; } .kx-adm-rules { width: 100%; border-collapse: collapse; }
.kx-adm-rules th, .kx-adm-rules td { padding: var(--space-3) var(--space-4); text-align: left; font-size: var(--fs-body); } .kx-adm-rules th, .kx-adm-rules td { padding: 10px 12px; text-align: left; font-size: var(--fs-body); }
.kx-adm-rules thead th { .kx-adm-rules thead th {
background: var(--color-neutral-100); background: var(--color-neutral-050);
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 700; font-weight: 600;
color: var(--color-neutral-500); color: var(--color-neutral-500);
text-transform: uppercase; letter-spacing: 0.02em;
border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap;
vertical-align: bottom;
} }
.kx-adm-rules tbody tr { border-bottom: 1px solid var(--color-neutral-100); } .kx-adm-rules tbody tr { border-bottom: 1px solid var(--color-neutral-200); }
.kx-adm-rulename { display: block; font-weight: 600; color: var(--color-neutral-900); } .kx-adm-rulename { display: block; font-weight: 600; color: var(--color-neutral-900); }
.kx-adm-rulecode { display: block; font-size: 11px; color: var(--color-neutral-500); font-family: ui-monospace, monospace; } .kx-adm-rulecode { display: block; font-size: 11px; color: var(--color-neutral-500); font-family: ui-monospace, monospace; }
.kx-adm-disclaimer { padding: var(--space-3) var(--space-4); font-size: var(--fs-caption); color: var(--color-neutral-500); border-top: var(--border-card); } .kx-adm-disclaimer { padding: var(--space-3) var(--space-4); font-size: var(--fs-caption); color: var(--color-neutral-500); border-top: var(--border-card); }
@ -343,15 +347,18 @@
} }
.kx-adm-tenant__lhead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); } .kx-adm-tenant__lhead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); }
.kx-adm-tenants { width: 100%; border-collapse: collapse; } .kx-adm-tenants { width: 100%; border-collapse: collapse; }
.kx-adm-tenants th, .kx-adm-tenants td { padding: var(--space-3) var(--space-4); text-align: left; font-size: var(--fs-body); } .kx-adm-tenants th, .kx-adm-tenants td { padding: 10px 12px; text-align: left; font-size: var(--fs-body); }
.kx-adm-tenants thead th { .kx-adm-tenants thead th {
background: var(--color-neutral-100); background: var(--color-neutral-050);
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 700; font-weight: 600;
color: var(--color-neutral-500); color: var(--color-neutral-500);
text-transform: uppercase; letter-spacing: 0.02em;
border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap;
vertical-align: bottom;
} }
.kx-adm-tenants tbody tr { border-bottom: 1px solid var(--color-neutral-100); } .kx-adm-tenants tbody tr { border-bottom: 1px solid var(--color-neutral-200); }
.kx-adm-tenants tbody tr:hover { background: var(--color-primary-050); } .kx-adm-tenants tbody tr:hover { background: var(--color-primary-050); }
.kx-adm-tenants tbody tr.is-selected { background: var(--color-primary-050); } .kx-adm-tenants tbody tr.is-selected { background: var(--color-primary-050); }
.kx-adm-tenants__name { font-weight: 600; color: var(--color-neutral-900); } .kx-adm-tenants__name { font-weight: 600; color: var(--color-neutral-900); }
@ -407,22 +414,22 @@
.kx-adm-tbl { width: 100%; border-collapse: collapse; } .kx-adm-tbl { width: 100%; border-collapse: collapse; }
.kx-adm-tbl th, .kx-adm-tbl th,
.kx-adm-tbl td { .kx-adm-tbl td {
padding: var(--space-3) var(--space-4); padding: 10px 12px;
text-align: left; text-align: left;
font-size: var(--fs-body); font-size: var(--fs-body);
vertical-align: middle; vertical-align: middle;
} }
.kx-adm-tbl thead th { .kx-adm-tbl thead th {
background: var(--color-neutral-100); background: var(--color-neutral-050);
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 700; font-weight: 600;
color: var(--color-neutral-500); color: var(--color-neutral-500);
text-transform: uppercase; letter-spacing: 0.02em;
letter-spacing: 0.03em; border-bottom: 2px solid var(--color-neutral-200);
border-bottom: var(--border-card);
white-space: nowrap; white-space: nowrap;
vertical-align: bottom;
} }
.kx-adm-tbl tbody tr { border-bottom: 1px solid var(--color-neutral-100); } .kx-adm-tbl tbody tr { border-bottom: 1px solid var(--color-neutral-200); }
.kx-adm-tbl tbody tr:hover { background: var(--color-primary-050); } .kx-adm-tbl tbody tr:hover { background: var(--color-primary-050); }
.kx-adm-tbl tbody tr.is-selected { background: var(--color-primary-050); } .kx-adm-tbl tbody tr.is-selected { background: var(--color-primary-050); }
.kx-adm-tbl__strong { font-weight: 600; color: var(--color-neutral-900); } .kx-adm-tbl__strong { font-weight: 600; color: var(--color-neutral-900); }

View File

@ -0,0 +1,249 @@
/*
* W12 부서 관리 조직도 WISE(UIWS system/dept·org) 이식 전용 스타일.
* 부서 트리 + 상세/편집 2-pane. design.md §1 토큰만 사용(임의 hex 최소).
* admin-modules.css의 테이블 규칙과 겹치지 않도록 .kx-dept-* 전용 네임스페이스.
*/
/* ── 헤더 액션 ── */
.kx-dept-headActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ── 통합검색 ── */
.kx-dept-toolbar {
margin-top: var(--space-4);
max-width: 420px;
}
.kx-dept-search {
position: relative;
display: flex;
align-items: center;
}
.kx-dept-search__icon {
position: absolute;
left: 12px;
color: var(--color-neutral-500);
pointer-events: none;
}
.kx-dept-search__input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-900);
font-size: var(--fs-body);
}
.kx-dept-search__input:focus {
outline: 2px solid var(--color-primary-100);
border-color: var(--color-primary-600);
}
/* ── 2-pane 본문 ── */
.kx-dept-body {
margin-top: var(--space-4);
display: flex;
gap: var(--space-4);
align-items: stretch;
min-height: 0;
}
.kx-dept-pane {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: auto;
}
.kx-dept-tree-pane {
flex: 0 0 320px;
min-width: 280px;
max-width: 360px;
padding: var(--space-3);
}
.kx-dept-detail-pane {
flex: 1 1 auto;
min-width: 0;
padding: var(--space-4);
}
/* ── 좌: 트리 ── */
.kx-dept-tree {
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-dept-tree__empty {
padding: var(--space-5) var(--space-3);
text-align: center;
color: var(--color-neutral-500);
font-size: var(--fs-caption);
}
.kx-dept-tree__row {
position: relative;
display: flex;
align-items: center;
gap: 6px;
height: 38px;
padding-right: 10px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-neutral-900);
transition: background 120ms ease;
}
.kx-dept-tree__row:hover {
background: var(--color-neutral-050);
}
.kx-dept-tree__row.is-selected {
background: var(--color-primary-050);
}
.kx-dept-tree__row.is-selected::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: var(--radius-pill);
background: var(--color-primary-600);
}
.kx-dept-tree__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
border: none;
background: transparent;
color: var(--color-neutral-500);
cursor: pointer;
padding: 0;
}
.kx-dept-tree__toggle--empty { cursor: default; }
.kx-dept-tree__icon {
flex: 0 0 auto;
color: var(--color-primary-600);
}
.kx-dept-tree__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-body);
font-weight: 500;
}
.kx-dept-tree__row.is-selected .kx-dept-tree__name {
font-weight: 700;
color: var(--color-primary-700);
}
.kx-dept-tree__sort {
flex: 0 0 auto;
color: var(--color-neutral-500);
font-size: var(--fs-caption);
}
.kx-dept-tree__off {
flex: 0 0 auto;
padding: 1px 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-100);
color: var(--color-neutral-500);
font-size: var(--fs-caption);
}
.kx-dept-mark {
background: var(--color-primary-100);
color: var(--color-primary-700);
border-radius: 2px;
padding: 0 1px;
}
/* ── 우: 상세/편집 ── */
.kx-dept-detail__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
color: var(--color-neutral-500);
text-align: center;
}
.kx-dept-detail__head {
position: sticky;
top: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
background: var(--color-white);
padding-bottom: var(--space-3);
margin-bottom: var(--space-3);
border-bottom: 1px solid var(--color-neutral-200);
z-index: 1;
}
.kx-dept-detail__headMain { min-width: 0; }
.kx-dept-detail__title {
font-size: var(--fs-h2);
font-weight: 700;
color: var(--color-neutral-900);
margin: 0;
}
.kx-dept-detail__path {
margin: 2px 0 0;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-dept-detail__actions {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
flex: 0 0 auto;
}
.kx-dept-detail__list { margin: 0; }
.kx-dept-detail__item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-neutral-200);
}
.kx-dept-detail__item:first-child { border-top: none; }
.kx-dept-detail__item dt {
flex: 0 0 110px;
color: var(--color-neutral-500);
font-size: var(--fs-caption);
margin: 0;
}
.kx-dept-detail__item dd {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--color-neutral-900);
font-size: var(--fs-body);
}
.kx-dept-code {
font-family: var(--font-mono, monospace);
font-size: 0.9em;
color: var(--color-neutral-700);
}
/* ── 반응형 ── */
@media (max-width: 1023px) {
.kx-dept-tree-pane { flex-basis: 260px; min-width: 230px; }
}
@media (max-width: 640px) {
.kx-dept-body { flex-direction: column; gap: var(--space-3); }
.kx-dept-tree-pane {
flex: 1 1 auto;
max-width: none;
min-width: 0;
max-height: 320px;
}
}
@media (prefers-reduced-motion: reduce) {
.kx-dept-tree__row { transition: none; }
}

View File

@ -0,0 +1,48 @@
/*
* API . (../../api/client) .
* 정본: 백엔드 com.zioinfo.kintex.system.holiday.HolidayController (V41 holiday, PK tenant_id+ymd).
* GET /api/admin/holidays?year=&from=&to= (ADMIN, off )
* GET /api/admin/holidays/years (ADMIN, )
* POST /api/admin/holidays (ADMIN, · PK 409)
* DELETE /api/admin/holidays/{ymd} (ADMIN)
* PATCH /api/admin/holidays/{ymd} (ADMIN, is_holiday )
* GET /api/holidays?from=&to= (, )
* ·(2024~2050) V41 API는 · .
*/
import { api } from '../../api/client';
export interface Holiday {
ymd: string; // yyyy-MM-dd
name: string;
isSubstitute: boolean;
isHoliday: boolean;
}
export interface HolidayCreateBody {
ymd: string;
name: string;
isSubstitute?: boolean;
}
export const holidayApi = {
/** 관리자 — 연도별(또는 from/to) 목록. */
adminList: (year: number) => api.get<Holiday[]>(`/api/admin/holidays?year=${year}`),
/** 관리자 — 데이터 존재 연도 목록(내림차순). */
years: () => api.get<number[]>('/api/admin/holidays/years'),
/** 관리자 — 임시공휴일 추가. */
create: (body: HolidayCreateBody) => api.post<Holiday>('/api/admin/holidays', body),
/** 관리자 — 삭제. */
remove: (ymd: string) => api.del<void>(`/api/admin/holidays/${encodeURIComponent(ymd)}`),
/** 관리자 — 휴무 여부 토글(임시휴무 on/off). */
toggle: (ymd: string, isHoliday: boolean) =>
api.patch<void>(`/api/admin/holidays/${encodeURIComponent(ymd)}`, { isHoliday }),
};
/** 캘린더 붉은색 표시용 — 기간 내 실제 휴무만(인증). ymd → 공휴일명 Map 으로 변환해 반환. */
export async function fetchHolidaySet(from: string, to: string): Promise<Map<string, string>> {
const list = await api.get<Holiday[]>(
`/api/holidays?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
);
const m = new Map<string, string>();
for (const h of list ?? []) m.set(h.ymd, h.name);
return m;
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
@ -17,7 +18,9 @@ type Tab = 'nlquery' | 'rules';
export function AiConsolePanel() { export function AiConsolePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('nlquery'); // 홈 AI 허브 딥링크(/analytics?tab=rules) 정합 — URL tab 파라미터로 초기 탭 결정.
const [searchParams] = useSearchParams();
const [tab, setTab] = useState<Tab>(searchParams.get('tab') === 'rules' ? 'rules' : 'nlquery');
return ( return (
<section className="kx-card kx-aicon" aria-label={t('ai.console.title')}> <section className="kx-card kx-aicon" aria-label={t('ai.console.title')}>

View File

@ -0,0 +1,283 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { IconArrowLeft } from '../../components/ui/icons';
import { approvalApi, type ApprovalDetail, type ApprovalSaveRequest } from './approvalApi';
import { ApprovalLineEditor, type ApprovalLineDraft } from './ApprovalLineEditor';
import './approval-form.css';
/*
* §5B (F028) / WISE/UIWS ApprovalFormPage .
* - mode='create' : (···) (DRAFT) (SUBMITTED).
* - mode='resubmit': (REJECTED) (//) (version+1).
* · ( ).
* App.tsx - .
* (ComposeForm) useState .
*/
const DOC_TYPES = ['GENERAL', 'DESIGN', 'DOCUMENT', 'EXPENSE', 'ETC'];
const dt = (v: string | null) => (v ? v.replace('T', ' ').slice(0, 16) : '-');
export function ApprovalComposePanel({
mode,
approvalId,
eventId,
onDone,
onCancel,
}: {
mode: 'create' | 'resubmit';
approvalId?: string;
eventId?: string;
onDone: (id: string, msg: string) => void;
onCancel: () => void;
}) {
const detailQ = useQuery({
queryKey: ['approval', approvalId],
queryFn: () => approvalApi.get(approvalId as string),
enabled: mode === 'resubmit' && !!approvalId,
});
if (mode === 'resubmit' && approvalId) {
if (detailQ.isLoading) {
return (
<div className="kx-apf">
<div className="kx-apf__card">
<Skeleton height={22} width="50%" />
<Skeleton height={16} />
<Skeleton height={120} />
</div>
</div>
);
}
if (detailQ.isError || !detailQ.data) {
return (
<div className="kx-apf">
<ErrorState onRetry={() => detailQ.refetch()} />
</div>
);
}
return (
<ComposeForm
mode="resubmit"
source={detailQ.data}
approvalId={approvalId}
eventId={eventId}
onDone={onDone}
onCancel={onCancel}
/>
);
}
return <ComposeForm mode="create" eventId={eventId} onDone={onDone} onCancel={onCancel} />;
}
function ComposeForm({
mode,
source,
approvalId,
eventId,
onDone,
onCancel,
}: {
mode: 'create' | 'resubmit';
source?: ApprovalDetail;
approvalId?: string;
eventId?: string;
onDone: (id: string, msg: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [docType, setDocType] = useState(source?.docType ?? 'GENERAL');
const [title, setTitle] = useState(source?.title ?? '');
const [content, setContent] = useState(source?.content ?? '');
const [lines, setLines] = useState<ApprovalLineDraft[]>(
source?.lines?.length
? source.lines.map((l) => ({ approverId: l.approverId, approverName: l.approverName ?? '' }))
: [{ approverId: '', approverName: '' }],
);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// 반려 사유(가장 최근 REJECT 이력) — 재상신 근거 표시.
const rejectReason = useMemo(() => {
if (!source) return null;
const rej = [...source.history].reverse().find((h) => h.action === 'REJECT');
return rej?.comment ?? null;
}, [source]);
const buildPayload = (): ApprovalSaveRequest => {
const filled = lines.filter((l) => l.approverId.trim());
return {
eventId: eventId || undefined,
docType,
title: title.trim(),
content: content.trim() || undefined,
lines: filled.map((l, i) => ({
stepNo: i + 1,
approverId: l.approverId.trim(),
approverName: l.approverName.trim() || undefined,
})),
};
};
const validate = (requireLine: boolean): string | null => {
if (!title.trim()) return t('approval.err.titleRequired');
if (requireLine && lines.filter((l) => l.approverId.trim()).length === 0) {
return t('approval.err.lineRequired');
}
return null;
};
const run = async (requireLine: boolean, fn: () => Promise<string>, msg: string) => {
const v = validate(requireLine);
if (v) return setError(v);
setError(null);
setBusy(true);
try {
const id = await fn();
onDone(id, msg);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
// 임시저장(DRAFT).
const saveDraft = () =>
run(false, async () => (await approvalApi.create(buildPayload())).id, t('approval.createdDraft'));
// 상신(create → submit).
const submitDoc = () =>
run(true, async () => {
const created = await approvalApi.create(buildPayload());
await approvalApi.submit(created.id);
return created.id;
}, t('approval.submittedDone'));
// 재상신(REJECTED→SUBMITTED, version+1).
const doResubmit = () =>
run(true, async () => {
await approvalApi.resubmit(approvalId as string, buildPayload());
return approvalId as string;
}, t('approval.resubmittedDone'));
return (
<div className="kx-apf">
<header className="kx-apf__head">
<button className="kx-apf__back" onClick={onCancel} type="button">
<IconArrowLeft size={16} /> {t('approval.backToList')}
</button>
<h1>{mode === 'resubmit' ? t('approval.resubmitTitle') : t('approval.composeTitle')}</h1>
{mode === 'resubmit' && source ? (
<span className="kx-apf__ver">
{t('approval.version')}: v{source.version} v{source.version + 1}
</span>
) : null}
</header>
{error && (
<div className="kx-apf__error" role="alert">
{error}
</div>
)}
{/* 반려 사유·이전 이력 — 재상신 근거 */}
{mode === 'resubmit' && source ? (
<div className="kx-apf__reject">
<div className="kx-apf__reject-head">
<strong>{t('approval.rejectedReason')}</strong>
</div>
<p className="kx-apf__reject-body">{rejectReason ?? '-'}</p>
{source.history.length > 0 ? (
<ul className="kx-apf__prev">
{source.history.map((h) => (
<li key={h.id}>
<span className="kx-apf__prev-act">{t(`approval.action.${h.action}`)}</span>
<span className="kx-apf__prev-meta">
{(h.actorName ?? h.actorId)} · {dt(h.actedAt)}
</span>
{h.comment ? <span className="kx-apf__prev-cmt"> {h.comment}</span> : null}
</li>
))}
</ul>
) : null}
</div>
) : null}
<form className="kx-apf__card" onSubmit={(e) => e.preventDefault()}>
<div className="kx-apf__row">
<label className="kx-apf__field kx-apf__field--type">
<span className="kx-apf__label">{t('approval.docType')}</span>
<select
className="kx-apf__select"
value={docType}
disabled={busy}
onChange={(e) => setDocType(e.target.value)}
>
{DOC_TYPES.map((code) => (
<option key={code} value={code}>
{t(`approval.docTypes.${code}`)}
</option>
))}
</select>
</label>
<label className="kx-apf__field kx-apf__field--title">
<span className="kx-apf__label">
{t('approval.titleLabel')} <em className="kx-apf__req">*</em>
</span>
<input
className="kx-apf__input"
value={title}
disabled={busy}
placeholder={t('approval.titlePlaceholder')}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</div>
<label className="kx-apf__field">
<span className="kx-apf__label">{t('approval.contentLabel')}</span>
<textarea
className="kx-apf__textarea"
rows={7}
value={content}
disabled={busy}
placeholder={t('approval.contentPlaceholder')}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<div className="kx-apf__field">
<span className="kx-apf__label">
{t('approval.line')} <em className="kx-apf__req">*</em>
</span>
<ApprovalLineEditor lines={lines} onChange={setLines} disabled={busy} />
</div>
<div className="kx-apf__actions">
<Button variant="ghost" type="button" disabled={busy} onClick={onCancel}>
{t('approval.cancel')}
</Button>
{mode === 'create' ? (
<>
<Button variant="secondary" type="button" disabled={busy} onClick={saveDraft}>
{t('approval.draftSave')}
</Button>
<Button type="button" disabled={busy} onClick={submitDoc}>
{busy ? t('approval.saving') : t('approval.submitDoc')}
</Button>
</>
) : (
<Button type="button" disabled={busy} onClick={doResubmit}>
{busy ? t('approval.saving') : t('approval.resubmitBtn')}
</Button>
)}
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,104 @@
import { useTranslation } from 'react-i18next';
import { IconChevronDown, IconClose, IconPlus } from '../../components/ui/icons';
/*
* §5B (F028) UI WISE/UIWS ApprovalFormPage .
* kintex (07_work_api_gaps.md, MessagePage )
* ID + (). (stepNo=index+1) .
*/
export interface ApprovalLineDraft {
approverId: string;
approverName: string;
}
export function ApprovalLineEditor({
lines,
onChange,
disabled,
}: {
lines: ApprovalLineDraft[];
onChange: (next: ApprovalLineDraft[]) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const patch = (idx: number, p: Partial<ApprovalLineDraft>) =>
onChange(lines.map((l, i) => (i === idx ? { ...l, ...p } : l)));
const add = () => onChange([...lines, { approverId: '', approverName: '' }]);
const remove = (idx: number) => onChange(lines.filter((_, i) => i !== idx));
const move = (idx: number, dir: -1 | 1) => {
const j = idx + dir;
if (j < 0 || j >= lines.length) return;
const next = [...lines];
[next[idx], next[j]] = [next[j], next[idx]];
onChange(next);
};
return (
<div className="kx-line-editor">
{lines.length === 0 ? (
<p className="kx-line-editor__empty">{t('approval.lineEditor.empty')}</p>
) : (
<ol className="kx-line-editor__list">
{lines.map((l, idx) => (
<li className="kx-line-editor__row" key={idx}>
<span className="kx-line-editor__step" aria-hidden="true">
{t('approval.lineEditor.stepNo', { n: idx + 1 })}
</span>
<input
className="kx-line-editor__id"
value={l.approverId}
disabled={disabled}
placeholder={t('approval.lineEditor.idPlaceholder')}
aria-label={t('approval.lineEditor.approverId', { n: idx + 1 })}
onChange={(e) => patch(idx, { approverId: e.target.value })}
/>
<input
className="kx-line-editor__name"
value={l.approverName}
disabled={disabled}
placeholder={t('approval.lineEditor.namePlaceholder')}
aria-label={t('approval.lineEditor.approverName', { n: idx + 1 })}
onChange={(e) => patch(idx, { approverName: e.target.value })}
/>
<div className="kx-line-editor__ops">
<button
type="button"
className="kx-line-editor__op"
disabled={disabled || idx === 0}
aria-label={t('approval.lineEditor.moveUp')}
onClick={() => move(idx, -1)}
>
<span className="kx-line-editor__up" aria-hidden="true">
<IconChevronDown size={15} />
</span>
</button>
<button
type="button"
className="kx-line-editor__op"
disabled={disabled || idx === lines.length - 1}
aria-label={t('approval.lineEditor.moveDown')}
onClick={() => move(idx, 1)}
>
<IconChevronDown size={15} />
</button>
<button
type="button"
className="kx-line-editor__op kx-line-editor__op--del"
disabled={disabled}
aria-label={t('approval.lineEditor.remove')}
onClick={() => remove(idx)}
>
<IconClose size={14} />
</button>
</div>
</li>
))}
</ol>
)}
<button type="button" className="kx-line-editor__add" disabled={disabled} onClick={add}>
<IconPlus size={15} /> {t('approval.lineEditor.add')}
</button>
</div>
);
}

View File

@ -1,17 +1,26 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconSearch } from '../../components/ui/icons'; import { IconPlus, IconSearch } from '../../components/ui/icons';
import { approvalApi, type ApprovalStatus } from './approvalApi'; import { approvalApi, type ApprovalBox, type ApprovalListItem, type ApprovalStatus } from './approvalApi';
import { ApprovalComposePanel } from './ApprovalComposePanel';
import './approval-queue.css'; import './approval-queue.css';
/* /*
* SCR-10 (·). §5B (F028) . * SCR-10 (··). §5B (F028) WISE .
* - API(box=PENDING: 내가 ) . * - : 결재할 (PENDING) · (DRAFTED) · (REJECTED, ) · (DONE).
* - '검수/열기' ReviewDetailsPage(/ ). 미변경: approvalId . * - 기안: 기안 + (ApprovalComposePanel mode=create).
* - 재상신: 반려/ REJECTED (mode=resubmit, version+1).
* - ReviewDetailsPage(/). 미변경: approvalId .
* App.tsx / - (?compose=1 / ?resubmit=<id> ).
*/ */
type TabKey = 'PENDING' | 'DRAFTED' | 'REJECTED' | 'DONE';
const TABS: TabKey[] = ['PENDING', 'DRAFTED', 'REJECTED', 'DONE'];
function statusTone(s: ApprovalStatus): string { function statusTone(s: ApprovalStatus): string {
switch (s) { switch (s) {
case 'APPROVED': case 'APPROVED':
@ -29,25 +38,88 @@ function statusTone(s: ApprovalStatus): string {
export function ManagerApprovalQueuePage() { export function ManagerApprovalQueuePage() {
const { eventId = '', hallId = 'H7', booth = 'A-101' } = useParams(); const { eventId = '', hallId = 'H7', booth = 'A-101' } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const [sp, setSp] = useSearchParams();
const [tab, setTab] = useState<TabKey>('PENDING');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [term, setTerm] = useState(''); const [term, setTerm] = useState('');
const [notice, setNotice] = useState<string | null>(null);
// 인-페이지 뷰: ?compose=1(기안) / ?resubmit=<id>(재상신) / 그 외 목록.
const composing = sp.get('compose') === '1';
const resubmitId = sp.get('resubmit');
const box: ApprovalBox = tab === 'REJECTED' ? 'DRAFTED' : (tab as ApprovalBox);
const listQ = useQuery({ const listQ = useQuery({
queryKey: ['approvals', 'PENDING', term], queryKey: ['approvals', box, term],
queryFn: () => approvalApi.list({ box: 'PENDING', keyword: term || undefined, page: 0, size: 100 }), queryFn: () => approvalApi.list({ box, keyword: term || undefined, page: 0, size: 100 }),
enabled: !composing && !resubmitId,
}); });
const rows = listQ.data?.items ?? [];
const allRows = listQ.data?.items ?? [];
const rows = tab === 'REJECTED' ? allRows.filter((r) => r.status === 'REJECTED') : allRows;
const statusLabel = (s: ApprovalStatus) => t(`approval.status.${s}`); const statusLabel = (s: ApprovalStatus) => t(`approval.status.${s}`);
const openCompose = () => {
setNotice(null);
setSp({ compose: '1' });
};
const openResubmit = (id: string) => {
setNotice(null);
setSp({ resubmit: id });
};
const backToList = () => setSp({});
const onDone = (_id: string, msg: string) => {
setNotice(msg);
setSp({});
qc.invalidateQueries({ queryKey: ['approvals'] });
};
// 행 클릭: 반려 문서(내 기안)는 재상신, 그 외는 검수/조회 화면으로.
const onRowOpen = (r: ApprovalListItem) => {
if (r.status === 'REJECTED') {
openResubmit(r.id);
return;
}
navigate(
`/events/${eventId}/halls/${hallId}/booths/${r.refId ?? booth}/review?approvalId=${encodeURIComponent(r.id)}`,
);
};
if (composing) {
return (
<ApprovalComposePanel
mode="create"
eventId={eventId || undefined}
onDone={onDone}
onCancel={backToList}
/>
);
}
if (resubmitId) {
return (
<ApprovalComposePanel
mode="resubmit"
approvalId={resubmitId}
eventId={eventId || undefined}
onDone={onDone}
onCancel={backToList}
/>
);
}
return ( return (
<div className="kx-aq"> <div className="kx-aq">
<header className="kx-aq__head"> <header className="kx-aq__head">
<div className="kx-aq__head-top"> <div className="kx-aq__head-top">
<div className="kx-aq__title-row"> <div className="kx-aq__title-row">
<h1>{t('approval.queueTitle')}</h1> <h1>{t('approval.boxTitle')}</h1>
</div> </div>
<div className="kx-aq__head-actions">
<form <form
className="kx-aq__search" className="kx-aq__search"
onSubmit={(e) => { onSubmit={(e) => {
@ -65,19 +137,41 @@ export function ManagerApprovalQueuePage() {
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
/> />
</form> </form>
<Button leadingIcon={<IconPlus size={16} />} onClick={openCompose}>
{t('approval.compose')}
</Button>
</div> </div>
<div className="kx-aq__stats">
<div className="kx-aq__stat">
<span>{t('approval.pendingCount')}</span>
<strong className="tnum kx-aq__stat--primary">{listQ.data?.total ?? 0}</strong>
</div> </div>
<div className="kx-aq__tabs" role="tablist" aria-label={t('approval.boxTitle')}>
{TABS.map((tk) => (
<button
key={tk}
type="button"
role="tab"
aria-selected={tab === tk}
className={`kx-aq__tab ${tab === tk ? 'is-active' : ''}`}
onClick={() => {
setTab(tk);
setNotice(null);
}}
>
{t(`approval.tab.${tk}`)}
</button>
))}
</div> </div>
</header> </header>
<div className="kx-aq__body"> <div className="kx-aq__body">
<section className="kx-aq__main" aria-label={t('approval.queueTitle')}> <section className="kx-aq__main" aria-label={t('approval.boxTitle')}>
{notice && (
<div className="kx-aq__notice" role="status">
{notice}
</div>
)}
<div className="kx-aq__main-head"> <div className="kx-aq__main-head">
<h2>{t('approval.pendingList')}</h2> <h2>{t(`approval.tab.${tab}`)}</h2>
<span className="kx-aq__count tnum">{rows.length}</span>
</div> </div>
<div className="kx-aq__table-wrap"> <div className="kx-aq__table-wrap">
{listQ.isLoading ? ( {listQ.isLoading ? (
@ -89,7 +183,7 @@ export function ManagerApprovalQueuePage() {
) : listQ.isError ? ( ) : listQ.isError ? (
<ErrorState onRetry={() => listQ.refetch()} /> <ErrorState onRetry={() => listQ.refetch()} />
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<EmptyState title={t('approval.emptyPending')} /> <EmptyState title={t(`approval.empty.${tab}`)} />
) : ( ) : (
<table className="kx-aq__table"> <table className="kx-aq__table">
<thead> <thead>
@ -110,25 +204,18 @@ export function ManagerApprovalQueuePage() {
<div className="kx-aq__event">{r.title}</div> <div className="kx-aq__event">{r.title}</div>
{r.refId ? <div className="kx-aq__booth tnum">{r.refId}</div> : null} {r.refId ? <div className="kx-aq__booth tnum">{r.refId}</div> : null}
</td> </td>
<td>{r.docType}</td> <td>{t(`approval.docTypes.${r.docType}`, { defaultValue: r.docType })}</td>
<td className="kx-aq__muted">{r.drafterName ?? r.drafterId}</td> <td className="kx-aq__muted">{r.drafterName ?? r.drafterId}</td>
<td className="kx-aq__muted tnum">{(r.submittedAt ?? '').slice(0, 10)}</td> <td className="kx-aq__muted tnum">{(r.submittedAt ?? '').slice(0, 10) || '-'}</td>
<td className="tnum"> <td className="tnum">
{r.curStep}/{r.totalSteps} {r.totalSteps > 0 ? `${r.curStep}/${r.totalSteps}` : '-'}
</td> </td>
<td> <td>
<span className={`kx-aq__st ${statusTone(r.status)}`}>{statusLabel(r.status)}</span> <span className={`kx-aq__st ${statusTone(r.status)}`}>{statusLabel(r.status)}</span>
</td> </td>
<td className="kx-aq__col-act"> <td className="kx-aq__col-act">
<button <button className="kx-aq__review" onClick={() => onRowOpen(r)}>
className="kx-aq__review" {r.status === 'REJECTED' ? t('approval.resubmitBtn') : t('approval.open')}
onClick={() =>
navigate(
`/events/${eventId}/halls/${hallId}/booths/${r.refId ?? booth}/review?approvalId=${encodeURIComponent(r.id)}`,
)
}
>
{t('approval.open')}
</button> </button>
</td> </td>
</tr> </tr>

View File

@ -7,6 +7,7 @@ import { AiLabel } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { import {
IconChevronRight, IconChevronRight,
IconClose,
IconDownload, IconDownload,
IconExpand, IconExpand,
IconMinus, IconMinus,
@ -15,18 +16,22 @@ import {
} from '../../components/ui/icons'; } from '../../components/ui/icons';
import { approvalApi, type ApprovalLine } from './approvalApi'; import { approvalApi, type ApprovalLine } from './approvalApi';
import './review-details.css'; import './review-details.css';
import './approval-form.css';
/* /*
* SCR-11 . §5B (F028) . * SCR-11 . §5B (F028) .
* - : 문서/ ( ) / : ·· + /(). * - : 문서/ ( ) / : · + /().
* - approvalId ·(/ 403). * - approvalId ·(/ 403).
* (·AI ) . * ( ). ( ) .
* (·AI ) .
*/ */
const MOCK = `${import.meta.env.BASE_URL}mock/review_details`; const MOCK = `${import.meta.env.BASE_URL}mock/review_details`;
const BLUEPRINT = `${MOCK}/img1.jpg`; const BLUEPRINT = `${MOCK}/img1.jpg`;
const TABS = ['도면', '예상 사진 (참고용)', '버전 비교']; const TABS = ['도면', '예상 사진 (참고용)', '버전 비교'];
type ActionKind = 'approve' | 'reject';
function lineTone(s: ApprovalLine['lineStatus']): string { function lineTone(s: ApprovalLine['lineStatus']): string {
switch (s) { switch (s) {
case 'CURRENT': case 'CURRENT':
@ -51,8 +56,8 @@ export function ReviewDetailsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [overlay, setOverlay] = useState(true); const [overlay, setOverlay] = useState(true);
const [comment, setComment] = useState('');
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [actionKind, setActionKind] = useState<ActionKind | null>(null);
const detailQ = useQuery({ const detailQ = useQuery({
queryKey: ['approval', approvalId], queryKey: ['approval', approvalId],
@ -66,20 +71,20 @@ export function ReviewDetailsPage() {
}; };
const approveM = useMutation({ const approveM = useMutation({
mutationFn: () => approvalApi.approve(approvalId as string, comment.trim() || undefined), mutationFn: (comment: string) => approvalApi.approve(approvalId as string, comment.trim() || undefined),
onSuccess: () => { onSuccess: () => {
setNotice(t('approval.approvedDone')); setNotice(t('approval.approvedDone'));
setComment(''); setActionKind(null);
invalidate(); invalidate();
}, },
onError: (e: Error) => setNotice(e.message), onError: (e: Error) => setNotice(e.message),
}); });
const rejectM = useMutation({ const rejectM = useMutation({
mutationFn: () => approvalApi.reject(approvalId as string, comment.trim()), mutationFn: (comment: string) => approvalApi.reject(approvalId as string, comment.trim()),
onSuccess: () => { onSuccess: () => {
setNotice(t('approval.rejectedDone')); setNotice(t('approval.rejectedDone'));
setComment(''); setActionKind(null);
invalidate(); invalidate();
}, },
onError: (e: Error) => setNotice(e.message), onError: (e: Error) => setNotice(e.message),
@ -89,12 +94,17 @@ export function ReviewDetailsPage() {
const busy = approveM.isPending || rejectM.isPending; const busy = approveM.isPending || rejectM.isPending;
const statusLabel = d ? t(`approval.status.${d.status}`) : ''; const statusLabel = d ? t(`approval.status.${d.status}`) : '';
const doAction = (comment: string) => {
if (actionKind === 'approve') approveM.mutate(comment);
else if (actionKind === 'reject') rejectM.mutate(comment);
};
return ( return (
<div className="kx-rev"> <div className="kx-rev">
<header className="kx-rev__head"> <header className="kx-rev__head">
<div> <div>
<nav className="kx-rev__crumb" aria-label="경로"> <nav className="kx-rev__crumb" aria-label="경로">
<button onClick={() => navigate(-1)}>{t('approval.queueTitle')}</button> <button onClick={() => navigate(-1)}>{t('approval.boxTitle')}</button>
<span aria-hidden="true"> <span aria-hidden="true">
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</span> </span>
@ -175,7 +185,7 @@ export function ReviewDetailsPage() {
</div> </div>
</section> </section>
{/* 우: 실 결재선·이력·의견 */} {/* 우: 실 결재선 타임라인·이력·의견 */}
<aside className="kx-rev__panel" aria-label={t('approval.lineAria')}> <aside className="kx-rev__panel" aria-label={t('approval.lineAria')}>
{!approvalId ? ( {!approvalId ? (
<div className="kx-rev__ai-card"> <div className="kx-rev__ai-card">
@ -249,7 +259,7 @@ export function ReviewDetailsPage() {
</aside> </aside>
</div> </div>
{/* 하단 액션 — 실 승인/반려(현재 단계 결재자만) */} {/* 하단 액션 — 승인/반려(현재 단계 결재자) · 재상신(반려된 내 기안) */}
<footer className="kx-rev__footer"> <footer className="kx-rev__footer">
<div className="kx-rev__foot-left"> <div className="kx-rev__foot-left">
<p className="kx-rev__foot-note"> <p className="kx-rev__foot-note">
@ -258,32 +268,108 @@ export function ReviewDetailsPage() {
: approvalId && d : approvalId && d
? d.canAct ? d.canAct
? t('approval.canActHint') ? t('approval.canActHint')
: d.canResubmit
? t('approval.canResubmitHint')
: t('approval.notApproverHint') : t('approval.notApproverHint')
: t('approval.demoFootNote')} : t('approval.demoFootNote')}
</p> </p>
{approvalId && d?.canAct ? (
<input
className="kx-rev__comment-input"
placeholder={t('approval.commentPlaceholder')}
aria-label={t('approval.comment')}
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
) : null}
</div> </div>
<div className="kx-rev__foot-actions"> <div className="kx-rev__foot-actions">
{approvalId && d?.canResubmit ? (
<Button
variant="secondary"
onClick={() => navigate(`/work/approval?resubmit=${encodeURIComponent(d.id)}`)}
>
{t('approval.resubmitBtn')}
</Button>
) : null}
<Button <Button
variant="danger" variant="danger"
disabled={!approvalId || !d?.canAct || busy || comment.trim().length === 0} disabled={!approvalId || !d?.canAct || busy}
onClick={() => rejectM.mutate()} onClick={() => {
setNotice(null);
setActionKind('reject');
}}
> >
{t('approval.reject')} {t('approval.reject')}
</Button> </Button>
<Button disabled={!approvalId || !d?.canAct || busy} onClick={() => approveM.mutate()}> <Button
disabled={!approvalId || !d?.canAct || busy}
onClick={() => {
setNotice(null);
setActionKind('approve');
}}
>
{t('approval.approve')} {t('approval.approve')}
</Button> </Button>
</div> </div>
</footer> </footer>
{actionKind && (
<ActionModal
kind={actionKind}
busy={busy}
onClose={() => setActionKind(null)}
onConfirm={doAction}
/>
)}
</div>
);
}
function ActionModal({
kind,
busy,
onClose,
onConfirm,
}: {
kind: ActionKind;
busy: boolean;
onClose: () => void;
onConfirm: (comment: string) => void;
}) {
const { t } = useTranslation();
const [comment, setComment] = useState('');
const required = kind === 'reject';
const canConfirm = !busy && (!required || comment.trim().length > 0);
return (
<div className="kx-apm__backdrop" role="dialog" aria-modal="true" onClick={onClose}>
<div className="kx-apm" onClick={(e) => e.stopPropagation()}>
<div className="kx-apm__head">
<h2>{kind === 'reject' ? t('approval.rejectModalTitle') : t('approval.approveModalTitle')}</h2>
<button className="kx-apm__close" aria-label={t('approval.cancel')} onClick={onClose}>
<IconClose size={18} />
</button>
</div>
<div className="kx-apm__body">
<label className="kx-apm__label" htmlFor="kx-apm-comment">
{required ? t('approval.rejectReason') : t('approval.comment')}
{required ? <em style={{ color: 'var(--color-error)', fontStyle: 'normal' }}> *</em> : null}
</label>
<textarea
id="kx-apm-comment"
className="kx-apm__textarea"
rows={4}
value={comment}
placeholder={required ? t('approval.rejectReasonPlaceholder') : t('approval.commentOptional')}
onChange={(e) => setComment(e.target.value)}
/>
{required ? <span className="kx-apm__hint">{t('approval.rejectReasonHint')}</span> : null}
</div>
<div className="kx-apm__foot">
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('approval.cancel')}
</Button>
<Button
variant={kind === 'reject' ? 'danger' : 'primary'}
disabled={!canConfirm}
onClick={() => onConfirm(comment)}
>
{busy ? t('approval.saving') : kind === 'reject' ? t('approval.reject') : t('approval.approve')}
</Button>
</div>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,346 @@
/* §5B 전자결재(F028) 기안/재상신 패널 + 결재선 편집기 + 반려 모달 스타일. */
.kx-apf {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
overflow-y: auto;
height: 100%;
min-height: 0;
}
.kx-apf__head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-apf__head h1 {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-apf__back {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
color: var(--color-neutral-700);
border-radius: var(--radius-sm);
padding: 5px 12px;
font-size: var(--fs-caption);
cursor: pointer;
}
.kx-apf__back:hover {
background: var(--color-neutral-050);
}
.kx-apf__ver {
margin-left: auto;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.kx-apf__error {
border: 1px solid #fecdca;
background: #fef3f2;
color: var(--color-error);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: var(--fs-caption);
}
.kx-apf__reject {
border: 1px solid #fecdca;
background: #fffbfa;
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-apf__reject-head strong {
font-size: var(--fs-caption);
color: #b91c1c;
font-weight: 700;
}
.kx-apf__reject-body {
font-size: var(--fs-body);
color: var(--color-neutral-800);
line-height: var(--lh-body);
}
.kx-apf__prev {
list-style: none;
margin: 0;
padding: var(--space-2) 0 0;
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px dashed var(--color-neutral-200);
}
.kx-apf__prev li {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: baseline;
font-size: var(--fs-caption);
}
.kx-apf__prev-act {
font-weight: 700;
color: var(--color-neutral-800);
}
.kx-apf__prev-meta {
color: var(--color-neutral-500);
}
.kx-apf__prev-cmt {
color: var(--color-neutral-700);
}
.kx-apf__card {
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 900px;
}
.kx-apf__row {
display: flex;
gap: var(--space-4);
}
.kx-apf__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.kx-apf__field--type {
flex: 0 0 200px;
}
.kx-apf__field--title {
flex: 1;
}
.kx-apf__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
}
.kx-apf__req {
color: var(--color-error);
font-style: normal;
}
.kx-apf__input,
.kx-apf__select,
.kx-apf__textarea {
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: var(--fs-body);
color: var(--color-neutral-900);
background: var(--color-white);
width: 100%;
}
.kx-apf__textarea {
resize: vertical;
line-height: var(--lh-body);
}
.kx-apf__input:focus,
.kx-apf__select:focus,
.kx-apf__textarea:focus {
outline: none;
border-color: var(--color-primary-600);
}
.kx-apf__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
border-top: 1px solid var(--color-neutral-100);
padding-top: var(--space-4);
}
/* 결재선 편집기 */
.kx-line-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px dashed var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: var(--space-3);
}
.kx-line-editor__empty {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-line-editor__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-line-editor__row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-line-editor__step {
flex: 0 0 auto;
min-width: 52px;
text-align: center;
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-primary-700);
background: var(--color-primary-050);
border-radius: var(--radius-pill);
padding: 3px 8px;
}
.kx-line-editor__id {
flex: 0 0 200px;
}
.kx-line-editor__name {
flex: 1;
min-width: 120px;
}
.kx-line-editor__id,
.kx-line-editor__name {
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: 7px 10px;
font-size: var(--fs-body);
}
.kx-line-editor__id:focus,
.kx-line-editor__name:focus {
outline: none;
border-color: var(--color-primary-600);
}
.kx-line-editor__ops {
display: flex;
gap: 2px;
}
.kx-line-editor__op {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
color: var(--color-neutral-500);
border-radius: var(--radius-sm);
cursor: pointer;
}
.kx-line-editor__op:hover:not(:disabled) {
background: var(--color-neutral-050);
color: var(--color-neutral-800);
}
.kx-line-editor__op:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.kx-line-editor__op--del:hover:not(:disabled) {
color: var(--color-error);
border-color: #fecdca;
}
.kx-line-editor__up {
display: inline-flex;
transform: rotate(180deg);
}
.kx-line-editor__add {
display: inline-flex;
align-items: center;
gap: 4px;
align-self: flex-start;
border: 1px solid var(--color-primary-600);
background: var(--color-white);
color: var(--color-primary-600);
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: var(--fs-caption);
font-weight: 600;
cursor: pointer;
}
.kx-line-editor__add:hover:not(:disabled) {
background: var(--color-primary-050);
}
.kx-line-editor__add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 반려/승인 사유 모달 */
.kx-apm__backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: var(--space-4);
}
.kx-apm {
background: var(--color-white);
border-radius: var(--radius-lg);
width: 100%;
max-width: 440px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
.kx-apm__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-apm__head h2 {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-apm__close {
border: none;
background: none;
color: var(--color-neutral-500);
cursor: pointer;
display: inline-flex;
}
.kx-apm__body {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: 6px;
}
.kx-apm__label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-700);
}
.kx-apm__textarea {
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: var(--fs-body);
resize: vertical;
line-height: var(--lh-body);
}
.kx-apm__textarea:focus {
outline: none;
border-color: var(--color-primary-600);
}
.kx-apm__hint {
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-apm__foot {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4);
border-top: 1px solid var(--color-neutral-100);
}

View File

@ -129,17 +129,20 @@
font-size: var(--fs-body); font-size: var(--fs-body);
} }
.kx-aq__table thead { .kx-aq__table thead {
background: var(--color-primary-050);
position: sticky; position: sticky;
top: 0; top: 0;
} }
.kx-aq__table th { .kx-aq__table th {
text-align: left; text-align: left;
padding: var(--space-3); padding: 10px 12px;
font-size: var(--fs-caption); font-size: var(--fs-caption);
color: var(--color-neutral-500); color: var(--color-neutral-500);
font-weight: 600; font-weight: 600;
border-bottom: 1px solid var(--color-neutral-200); letter-spacing: 0.02em;
background: var(--color-neutral-050);
border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap;
vertical-align: bottom;
} }
.kx-aq__col-ai { .kx-aq__col-ai {
color: var(--color-ai-accent) !important; color: var(--color-ai-accent) !important;
@ -148,8 +151,8 @@
text-align: right; text-align: right;
} }
.kx-aq__table td { .kx-aq__table td {
padding: var(--space-3); padding: 10px 12px;
border-bottom: 1px solid var(--color-neutral-100); border-bottom: 1px solid var(--color-neutral-200);
vertical-align: middle; vertical-align: middle;
} }
.kx-aq__table tbody tr:hover { .kx-aq__table tbody tr:hover {
@ -289,6 +292,52 @@
} }
} }
/* F028 결재함 — 헤더 액션·탭·알림·카운트 */
.kx-aq__head-actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kx-aq__tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-neutral-200);
}
.kx-aq__tab {
border: none;
background: none;
padding: 8px 16px;
font-size: var(--fs-body);
color: var(--color-neutral-500);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.kx-aq__tab:hover {
color: var(--color-neutral-800);
}
.kx-aq__tab.is-active {
color: var(--color-primary-700);
font-weight: 700;
border-bottom-color: var(--color-primary-600);
}
.kx-aq__notice {
border: 1px solid #a6e0c8;
background: #e6f4ee;
color: #047857;
border-radius: var(--radius-sm);
padding: 8px 14px;
font-size: var(--fs-caption);
}
.kx-aq__count {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--color-primary-600);
background: var(--color-primary-050);
border-radius: var(--radius-pill);
padding: 2px 10px;
}
/* F028 실배선 — 상태 배지·스켈레톤 */ /* F028 실배선 — 상태 배지·스켈레톤 */
.kx-aq__skel { .kx-aq__skel {
display: flex; display: flex;

View File

@ -1046,24 +1046,30 @@
.kx-boq__scroll { .kx-boq__scroll {
overflow: auto; overflow: auto;
border: var(--border-card); border: var(--border-card);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
} }
.kx-boq__table { .kx-boq__table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: var(--fs-caption); font-size: var(--fs-body);
color: var(--color-neutral-700);
} }
.kx-boq__table th, .kx-boq__table th,
.kx-boq__table td { .kx-boq__table td {
padding: 8px 10px; padding: 10px 12px;
border-bottom: 1px solid var(--color-border-subtle, #eaecf0); border-bottom: 1px solid var(--color-neutral-200);
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
.kx-boq__table thead th { .kx-boq__table thead th {
background: var(--color-surface-subtle, #f9fafb); background: var(--color-neutral-050);
font-size: var(--fs-caption);
font-weight: 600; font-weight: 600;
color: var(--color-text-muted); letter-spacing: 0.02em;
color: var(--color-neutral-500);
border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap;
vertical-align: bottom;
position: sticky; position: sticky;
top: 0; top: 0;
} }

View File

@ -1000,17 +1000,19 @@
} }
.kx-i18n-table thead th { .kx-i18n-table thead th {
text-align: left; text-align: left;
padding: 10px var(--space-3); padding: 10px 12px;
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-neutral-500); color: var(--color-neutral-500);
background: var(--color-neutral-050); background: var(--color-neutral-050);
border-bottom: var(--border-card); border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap; white-space: nowrap;
vertical-align: bottom;
} }
.kx-i18n-table tbody td { .kx-i18n-table tbody td {
padding: 10px var(--space-3); padding: 10px 12px;
border-bottom: 1px solid var(--color-neutral-100); border-bottom: 1px solid var(--color-neutral-200);
color: var(--color-neutral-700); color: var(--color-neutral-700);
vertical-align: top; vertical-align: top;
} }

View File

@ -211,24 +211,30 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
/* 테이블 */ /* 테이블 — 그리드 표준(Nifty Advanced headers, shared.css 정의와 동일) */
.kx-table { .kx-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: var(--fs-body); font-size: var(--fs-body);
color: var(--color-neutral-700);
} }
.kx-table th { .kx-table thead th {
text-align: left; text-align: left;
padding: var(--space-2) var(--space-4); padding: 10px 12px;
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-neutral-500); color: var(--color-neutral-500);
background: var(--color-neutral-050); background: var(--color-neutral-050);
border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap;
vertical-align: bottom;
} }
.kx-table td { .kx-table tbody td {
padding: var(--space-2) var(--space-4); padding: 10px 12px;
height: var(--row-h); border-bottom: 1px solid var(--color-neutral-200);
border-top: 1px solid var(--color-neutral-100);
color: var(--color-neutral-700); color: var(--color-neutral-700);
vertical-align: middle;
} }
.kx-table tbody tr:hover { .kx-table tbody tr:hover {
background: var(--row-hover); background: var(--row-hover);

View File

@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Offcanvas } from '../../components/ui/Offcanvas';
import { IconWarning } from '../../components/ui/icons'; import { IconWarning } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { import {
@ -61,6 +62,9 @@ export function HallAssignmentPage() {
const [assignError, setAssignError] = useState<string | null>(null); const [assignError, setAssignError] = useState<string | null>(null);
const [conflictHits, setConflictHits] = useState<Conflict[]>([]); const [conflictHits, setConflictHits] = useState<Conflict[]>([]);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
// 배정 상세 offcanvas — 칩 본문 클릭 시 우측 슬라이드
const [detail, setDetail] = useState<HallAssignment | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const hallsQ = useQuery({ queryKey: ['halls'], queryFn: () => hallAssignApi.halls(), retry: false }); const hallsQ = useQuery({ queryKey: ['halls'], queryFn: () => hallAssignApi.halls(), retry: false });
const assignQ = useQuery({ const assignQ = useQuery({
@ -121,6 +125,7 @@ export function HallAssignmentPage() {
mutationFn: (id: string) => hallAssignApi.release(id), mutationFn: (id: string) => hallAssignApi.release(id),
onSuccess: () => { onSuccess: () => {
setToast(t('hallassign.releasedToast')); setToast(t('hallassign.releasedToast'));
setDetailOpen(false);
queryClient.invalidateQueries({ queryKey: ['hall-assignments'] }); queryClient.invalidateQueries({ queryKey: ['hall-assignments'] });
}, },
onError: () => setToast(t('hallassign.releaseFail')), onError: () => setToast(t('hallassign.releaseFail')),
@ -133,6 +138,27 @@ export function HallAssignmentPage() {
setModalOpen(true); setModalOpen(true);
} }
/** 상세 → 수정: 배정 값을 배정 모달에 프리필(생성 API 재사용). */
function openModalWith(a: HallAssignment) {
setForm({
eventId: a.eventId,
hallId: a.hallId,
primary: a.primary,
occupyStart: a.occupyStart ?? '',
occupyEnd: a.occupyEnd ?? '',
note: a.note ?? '',
});
setAssignError(null);
setConflictHits([]);
setDetailOpen(false);
setModalOpen(true);
}
function openDetail(a: HallAssignment) {
setDetail(a);
setDetailOpen(true);
}
function onPickEvent(eventId: string) { function onPickEvent(eventId: string) {
const ev = eventsQ.data?.find((e) => e.id === eventId); const ev = eventsQ.data?.find((e) => e.id === eventId);
setForm((f) => ({ setForm((f) => ({
@ -210,12 +236,26 @@ export function HallAssignmentPage() {
key={a.id} key={a.id}
className={`kx-hall__chip${conflict ? ' is-conflict' : ''}${a.primary ? ' is-primary' : ''}`} className={`kx-hall__chip${conflict ? ' is-conflict' : ''}${a.primary ? ' is-primary' : ''}`}
title={`${a.occupyStart ?? '?'} ~ ${a.occupyEnd ?? '?'}`} title={`${a.occupyStart ?? '?'} ~ ${a.occupyEnd ?? '?'}`}
>
<span
className="kx-hall__chipbody"
role="button"
tabIndex={0}
aria-label={t('hallassign.openDetail', { event: a.eventName })}
onClick={() => openDetail(a)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openDetail(a);
}
}}
> >
{conflict && <IconWarning size={13} />} {conflict && <IconWarning size={13} />}
<span className="kx-hall__chiptext"> <span className="kx-hall__chiptext">
{a.eventName} {a.eventName}
<em>{a.occupyStart} ~ {a.occupyEnd}</em> <em>{a.occupyStart} ~ {a.occupyEnd}</em>
</span> </span>
</span>
<button <button
type="button" type="button"
className="kx-hall__chipx" className="kx-hall__chipx"
@ -309,10 +349,138 @@ export function HallAssignmentPage() {
</div> </div>
</div> </div>
)} )}
{/* ── 배정 상세 offcanvas(우측 슬라이드) ── */}
<Offcanvas
open={detailOpen}
onClose={() => setDetailOpen(false)}
title={detail ? detail.eventName : t('hallassign.detailTitle')}
footer={
detail ? (
<>
<Button variant="ghost" onClick={() => detail && openModalWith(detail)}>
{t('hallassign.edit')}
</Button>
<Button
variant="danger"
disabled={releaseMut.isPending}
onClick={() => {
if (detail && window.confirm(t('hallassign.releaseConfirm', { event: detail.eventName, hall: detail.hallLabel }))) {
releaseMut.mutate(detail.id);
}
}}
>
{t('hallassign.release')}
</Button>
</>
) : undefined
}
>
{detail && <AssignmentDetail assignment={detail} allAssignments={assignments} />}
</Offcanvas>
</div> </div>
); );
} }
/** 배정 상세 본문 — 기간·홀·상태·충돌 행사·자동 견적 요약. */
function AssignmentDetail({ assignment, allAssignments }: { assignment: HallAssignment; allAssignments: HallAssignment[] }) {
const { t } = useTranslation();
const a = assignment;
// 같은 홀에서 이 배정과 겹치는 다른 배정(충돌).
const conflicts = useMemo(
() =>
allAssignments.filter(
(o) => o.id !== a.id && o.hallId === a.hallId && overlaps(a.occupyStart, a.occupyEnd, o.occupyStart, o.occupyEnd),
),
[allAssignments, a],
);
// 자동 견적 요약(홀+기간) — 룰셋 재사용. 기간이 있을 때만.
const quoteQ = useQuery({
queryKey: ['hall-assign-detail-quote', a.hallId, a.occupyStart, a.occupyEnd],
queryFn: () => hallAssignApi.quote({ hallIds: [a.hallId], startDate: a.occupyStart!, endDate: a.occupyEnd! }),
enabled: detailQuoteReady(a),
retry: false,
staleTime: 60_000,
});
return (
<div className="kx-hall__detail">
<dl className="kx-hall__detailgrid">
<div>
<dt>{t('hallassign.dHall')}</dt>
<dd>{a.hallLabel}</dd>
</div>
<div>
<dt>{t('hallassign.dPeriod')}</dt>
<dd>{a.occupyStart ?? '?'} ~ {a.occupyEnd ?? '?'}</dd>
</div>
<div>
<dt>{t('hallassign.dStatus')}</dt>
<dd>
<span className={`kx-hall__statusbadge${a.primary ? ' is-primary' : ''}`}>{a.status || '-'}</span>
{a.primary && <span className="kx-hall__primarytag">{t('hallassign.primaryTag')}</span>}
</dd>
</div>
{a.note && (
<div>
<dt>{t('hallassign.dNote')}</dt>
<dd>{a.note}</dd>
</div>
)}
</dl>
{/* 충돌 여부 */}
<section className="kx-hall__detailsec">
<h3>{t('hallassign.dConflict')}</h3>
{conflicts.length === 0 ? (
<p className="kx-hall__ok">{t('hallassign.dNoConflict')}</p>
) : (
<ul className="kx-hall__conflicts kx-hall__detailconflicts">
{conflicts.map((c) => (
<li key={c.id}>
<IconWarning size={14} /> {c.eventName} · {c.hallLabel} ({c.occupyStart} ~ {c.occupyEnd})
</li>
))}
</ul>
)}
</section>
{/* 자동 견적 요약 */}
<section className="kx-hall__detailsec">
<h3>{t('hallassign.dQuote')}</h3>
{!detailQuoteReady(a) ? (
<p className="kx-card__hint">{t('hallassign.dQuoteNoPeriod')}</p>
) : quoteQ.isLoading ? (
<Skeleton height={72} />
) : quoteQ.error || !quoteQ.data ? (
<p className="kx-card__hint">{t('hallassign.dQuoteFail')}</p>
) : (
<div className="kx-hall__detailquote">
<div className="kx-hall__detailquoterow">
<span>{t('hallassign.rentalTotal', { days: quoteQ.data.days })}</span>
<strong className="kx-hall__num">{fmtKrw(quoteQ.data.total)}</strong>
</div>
<div className="kx-hall__detailquoterow">
<span>{t('hallassign.deposit')}</span>
<span className="kx-hall__num">{fmtKrw(quoteQ.data.depositMin)} ~ {fmtKrw(quoteQ.data.depositMax)}</span>
</div>
{quoteQ.data.disclaimer && (
<p className="kx-card__hint">{quoteQ.data.disclaimer} {t('hallassign.ruleset', { v: quoteQ.data.rulesetVersion })}</p>
)}
</div>
)}
</section>
</div>
);
}
/** 상세 견적 조회 가능 여부(홀·기간 확보). */
function detailQuoteReady(a: HallAssignment): boolean {
return Boolean(a.hallId && a.occupyStart && a.occupyEnd);
}
/** 자동 견적 패널 — 홀 다중 선택 + 기간 + 부스 규모 → 요율 룰셋 라인. */ /** 자동 견적 패널 — 홀 다중 선택 + 기간 + 부스 규모 → 요율 룰셋 라인. */
function QuotePanel({ halls }: { halls: Hall[] }) { function QuotePanel({ halls }: { halls: Hall[] }) {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@ -93,6 +93,20 @@
border-color: var(--color-error); border-color: var(--color-error);
color: var(--color-error); color: var(--color-error);
} }
.kx-hall__chipbody {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
border-radius: var(--radius-sm);
}
.kx-hall__chipbody:hover .kx-hall__chiptext {
text-decoration: underline;
}
.kx-hall__chipbody:focus-visible {
outline: 2px solid var(--color-brand-500, #1f29fc);
outline-offset: 2px;
}
.kx-hall__chiptext { .kx-hall__chiptext {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -245,6 +259,106 @@
padding-left: 18px; padding-left: 18px;
} }
/* 배정 상세 offcanvas 본문 */
.kx-hall__detail {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-hall__detailgrid {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin: 0;
}
.kx-hall__detailgrid > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-hall__detailgrid dt {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-hall__detailgrid dd {
margin: 0;
font-size: var(--fs-body);
color: var(--color-neutral-900);
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.kx-hall__statusbadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-hall__statusbadge.is-primary {
border-color: var(--color-brand-500, #1f29fc);
color: var(--color-brand-500, #1f29fc);
}
.kx-hall__primarytag {
font-size: var(--fs-caption);
color: var(--color-brand-500, #1f29fc);
}
.kx-hall__detailsec {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: var(--border-card);
}
.kx-hall__detailsec h3 {
font-size: var(--fs-body);
font-weight: 600;
color: var(--color-neutral-900);
margin: 0;
}
.kx-hall__ok {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
color: var(--color-success);
}
.kx-hall__detailconflicts {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.kx-hall__detailconflicts li {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
color: var(--color-error);
}
.kx-hall__detailquote {
display: flex;
flex-direction: column;
gap: 6px;
}
.kx-hall__detailquoterow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-hall__detailquoterow strong {
color: var(--color-neutral-900);
}
/* 토스트 */ /* 토스트 */
.kx-hall__toast { .kx-hall__toast {
position: fixed; position: fixed;

View File

@ -441,12 +441,13 @@ function BannerCarousel() {
/* /*
* *
* */ * */
const SHOWCASE_TABS: ShowcaseTab[] = ['ongoing', 'thisMonth', 'nextMonth']; // 탭 순서: 이번 달 → 다음 달 → 진행 중(맨 끝, 소유자 지시 2026-07-12). 기본=이번 달.
const SHOWCASE_TABS: ShowcaseTab[] = ['thisMonth', 'nextMonth', 'ongoing'];
function ShowcaseRail() { function ShowcaseRail() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState<ShowcaseTab>('ongoing'); const [tab, setTab] = useState<ShowcaseTab>('thisMonth');
const railRef = useRef<HTMLDivElement | null>(null); const railRef = useRef<HTMLDivElement | null>(null);
const [atStart, setAtStart] = useState(true); const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false); const [atEnd, setAtEnd] = useState(false);

View File

@ -369,12 +369,12 @@
color: var(--color-neutral-500); color: var(--color-neutral-500);
} }
/* ── ④ 본문 2열 그리드 ── */ /* ── ④ 본문 2열 그리드 ── 좌우 컬럼 하단 정렬(stretch): 최근 활동↔진행 중 전시 높이 일치 ── */
.kx-home__body { .kx-home__body {
display: grid; display: grid;
grid-template-columns: 8fr 4fr; grid-template-columns: 8fr 4fr;
gap: var(--space-5); gap: var(--space-5);
align-items: start; align-items: stretch;
} }
.kx-home__col-main, .kx-home__col-main,
.kx-home__col-side { .kx-home__col-side {
@ -383,6 +383,11 @@
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
} }
/* 각 컬럼 마지막 카드가 남은 높이를 채워 양 컬럼 하단선을 맞춘다(공백 없는 반응형). */
.kx-home__col-main > :last-child,
.kx-home__col-side > :last-child {
flex: 1 1 auto;
}
/* 캘린더 */ /* 캘린더 */
.kx-home__cal-head { .kx-home__cal-head {

View File

@ -0,0 +1,166 @@
/*
* SCR-T3 agency (§3C) `/agency`, · track="agency".
* + ( ) + () + · + CTA.
* ( ) . API + CTA( ).
* 데이터: 등록··= · () · =SCR-01 ( SCR-27/28).
*/
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import {
IconFactory,
IconShield,
IconInfo,
IconCheck,
IconArrowRight,
} from './publicIcons';
const REG_STEPS = ['apply', 'review', 'register', 'eligible'];
const SAFETY_CARDS: { key: string; icon: typeof IconShield }[] = [
{ key: 'report', icon: IconInfo },
{ key: 'prohibited', icon: IconShield },
{ key: 'docs', icon: IconCheck },
];
export function AgencyTrackPage() {
const { t } = useTranslation();
return (
<PublicShell active="exhibit" track="agency" cta={t('track.agency.ctaLogin')}>
{/* 에이전시 히어로 — 옥션 차별점 강조 */}
<section className="kxp-hero" aria-label={t('track.agency.heroAria')}>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #1a1330 0%, #3a2a6b 55%, #0066b3 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">
<IconFactory width={16} height={16} /> {t('track.agency.eyebrow')}
</span>
<h1 className="kxp-hero__title">{t('track.agency.title')}</h1>
<p className="kxp-hero__lead">{t('track.agency.subtitle')}</p>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#registry">
{t('track.agency.ctaRegister')}
</a>
<Link className="kxp-btn kxp-btn--ghost kxp-btn--lg" to="/login">
{t('track.agency.ctaLogin')}
</Link>
</div>
</div>
</div>
</section>
{/* 등록업체 안내 + 등록 게이트 고지 */}
<section className="kxp-section" id="registry" aria-label={t('track.agency.registryAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.agency.registryTitle')}</h2>
<p className="kxp-section__sub">{t('track.agency.registrySub')}</p>
</div>
</div>
<div className="kxp-notice-card kxp-notice-card--warn" role="note">
<span className="kxp-notice-card__ic" aria-hidden>
<IconInfo width={22} height={22} />
</span>
<div>
<div className="kxp-notice-card__title">{t('track.agency.gateTitle')}</div>
<p className="kxp-notice-card__body">{t('track.agency.gateBody')}</p>
</div>
</div>
<ol className="kxp-flow" style={{ marginTop: 20 }}>
{REG_STEPS.map((k, i) => (
<li key={k} className="kxp-flow__step">
<span className="kxp-flow__no">{i + 1}</span>
<span className="kxp-flow__title">{t(`track.agency.reg.${k}.title`)}</span>
<span className="kxp-flow__desc">{t(`track.agency.reg.${k}.desc`)}</span>
</li>
))}
</ol>
<div style={{ marginTop: 16 }}>
<a className="kxp-btn kxp-btn--primary" href="#login-cta">
{t('track.agency.ctaRegister')}
</a>
</div>
</div>
</section>
{/* 진행 중 공사 옥션 (역경매) — 차별점 강조 */}
<section className="kxp-section kxp-section--alt" aria-label={t('track.agency.auctionAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.agency.auctionTitle')}</h2>
<p className="kxp-section__sub">{t('track.agency.auctionSub')}</p>
</div>
</div>
{/* 공개 옥션 목록 API 미배포 — 안내 + 로그인 유도(빈 화면 금지) */}
<div className="kxp-notice-card" role="note">
<span className="kxp-notice-card__ic" aria-hidden style={{ color: 'var(--color-primary-600, #0066b3)' }}>
<IconFactory width={22} height={22} />
</span>
<div>
<div className="kxp-notice-card__title" style={{ color: 'var(--color-primary-700, #004c86)' }}>
{t('track.agency.auctionEmptyTitle')}
</div>
<p className="kxp-notice-card__body">{t('track.agency.auctionEmptyBody')}</p>
</div>
</div>
<div style={{ marginTop: 16 }}>
<Link className="kxp-btn kxp-btn--primary" to="/login">
{t('track.agency.ctaAuctionLogin')} <IconArrowRight width={16} height={16} />
</Link>
</div>
</div>
</section>
{/* 작업신고·안전 규정 */}
<section className="kxp-section" aria-label={t('track.agency.safetyAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.agency.safetyTitle')}</h2>
<p className="kxp-section__sub">{t('track.agency.safetySub')}</p>
</div>
</div>
<div className="kxp-guidegrid">
{SAFETY_CARDS.map(({ key, icon: Icon }) => (
<article key={key} className="kxp-guide">
<span className="kxp-guide__ic" aria-hidden>
<Icon width={22} height={22} />
</span>
<div>
<div className="kxp-guide__title">{t(`track.agency.safety.${key}.title`)}</div>
<p className="kxp-guide__desc">{t(`track.agency.safety.${key}.desc`)}</p>
</div>
</article>
))}
</div>
<div style={{ marginTop: 16 }}>
<Link className="kxp-btn kxp-btn--outline" to="/login">
{t('track.agency.ctaWorkReport')}
</Link>
</div>
</div>
</section>
{/* 등록업체 로그인 CTA 밴드 */}
<section className="kxp-section kxp-section--alt" id="login-cta" aria-label={t('track.agency.ctaBandAria')}>
<div className="kxp-wrap">
<div className="kxp-ctaband">
<span className="kxp-ctaband__text">{t('track.agency.ctaBandText')}</span>
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg" to="/login">
{t('track.agency.ctaLogin')}
</Link>
</div>
</div>
</section>
</PublicShell>
);
}

View File

@ -0,0 +1,184 @@
/*
* SCR-T2 business (§3C) `/business`, · track="business".
* + 2- (·) + + · + / CTA.
* 데이터: 안내··=(M1·M2·M4·M5·M6 ) · =SCR-P6 · =SCR-01 .
*/
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import {
IconVenue,
IconBuyer,
IconCheck,
IconSparkles,
IconArrowRight,
} from './publicIcons';
/** 주최자·참가 가치 항목. ai=true 면 보라색 AI 칩 표기. */
const VALUE_CARDS: { key: string; icon: typeof IconVenue; items: { k: string; ai?: boolean }[]; to: string }[] = [
{
key: 'organizer',
icon: IconVenue,
to: '/login',
items: [{ k: 'rental' }, { k: 'quote', ai: true }, { k: 'floorplan' }, { k: 'rate' }],
},
{
key: 'exhibitor',
icon: IconBuyer,
to: '/login',
items: [{ k: 'apply' }, { k: 'utility' }, { k: 'compliance', ai: true }, { k: 'render', ai: true }],
},
];
const PROCESS_STEPS = ['inquiry', 'quote', 'contract', 'assign', 'booth', 'inspect'];
const RATE_TYPES = ['shell', 'space', 'premium'];
const DOC_ITEMS = ['assign', 'flame', 'structure'];
export function BusinessTrackPage() {
const { t } = useTranslation();
return (
<PublicShell active="exhibit" track="business" cta={t('track.business.ctaApply')}>
{/* 비즈니스 히어로 */}
<section className="kxp-hero" aria-label={t('track.business.heroAria')}>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #12233b 0%, #23346b 60%, #0066b3 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">
<IconVenue width={16} height={16} /> {t('track.business.eyebrow')}
</span>
<h1 className="kxp-hero__title">{t('track.business.title')}</h1>
<p className="kxp-hero__lead">{t('track.business.subtitle')}</p>
<div className="kxp-hero__actions">
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg" to="/public/exhibit-inquiry">
{t('track.business.ctaApply')}
</Link>
<Link className="kxp-btn kxp-btn--ghost kxp-btn--lg" to="/login">
{t('track.business.ctaLogin')}
</Link>
</div>
</div>
</div>
</section>
{/* 2-분기 가치 제안 */}
<section className="kxp-section" aria-label={t('track.business.valueAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.business.valueTitle')}</h2>
<p className="kxp-section__sub">{t('track.business.valueSub')}</p>
</div>
</div>
<div className="kxp-valuegrid">
{VALUE_CARDS.map(({ key, icon: Icon, items, to }) => (
<article key={key} className="kxp-value">
<div className="kxp-value__head">
<span className="kxp-value__ic" aria-hidden>
<Icon width={24} height={24} />
</span>
<h3 className="kxp-value__title">{t(`track.business.value.${key}.title`)}</h3>
</div>
<p className="kxp-trackcard__desc">{t(`track.business.value.${key}.desc`)}</p>
<ul className="kxp-value__list">
{items.map(({ k, ai }) => (
<li key={k} className="kxp-value__li">
<IconCheck width={18} height={18} />
<span>{t(`track.business.value.${key}.item.${k}`)}</span>
{ai && (
<span className="kxp-aichip">
<IconSparkles width={11} height={11} /> AI
</span>
)}
</li>
))}
</ul>
<Link className="kxp-btn kxp-btn--primary" to={to}>
{t(`track.business.value.${key}.cta`)}
</Link>
</article>
))}
</div>
</div>
</section>
{/* 임대 절차 흐름 스텝 */}
<section className="kxp-section kxp-section--alt" aria-label={t('track.business.processAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.business.processTitle')}</h2>
<p className="kxp-section__sub">{t('track.business.processSub')}</p>
</div>
</div>
<ol className="kxp-flow">
{PROCESS_STEPS.map((k, i) => (
<li key={k} className="kxp-flow__step">
<span className="kxp-flow__no">{i + 1}</span>
<span className="kxp-flow__title">{t(`track.business.process.${k}.title`)}</span>
<span className="kxp-flow__desc">{t(`track.business.process.${k}.desc`)}</span>
</li>
))}
</ol>
</div>
</section>
{/* 요금·서류 안내 */}
<section className="kxp-section" aria-label={t('track.business.rateAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.business.rateTitle')}</h2>
<p className="kxp-section__sub">{t('track.business.rateSub')}</p>
</div>
</div>
<div className="kxp-trackcards">
{RATE_TYPES.map((k) => (
<article key={k} className="kxp-trackcard">
<span className="kxp-trackcard__label">{t(`track.business.rate.${k}.label`)}</span>
<h3 className="kxp-trackcard__title">{t(`track.business.rate.${k}.price`)}</h3>
<p className="kxp-trackcard__desc">{t(`track.business.rate.${k}.desc`)}</p>
</article>
))}
</div>
<div className="kxp-list" style={{ marginTop: 20 }}>
{DOC_ITEMS.map((k) => (
<div key={k} className="kxp-listrow">
<div className="kxp-listrow__main">
<div className="kxp-listrow__title">{t(`track.business.doc.${k}.title`)}</div>
<div className="kxp-listrow__meta">{t(`track.business.doc.${k}.desc`)}</div>
</div>
<span className="kxp-listrow__right">
<IconArrowRight width={18} height={18} />
</span>
</div>
))}
</div>
</div>
</section>
{/* 로그인/문의 CTA 밴드 */}
<section className="kxp-section kxp-section--alt" aria-label={t('track.business.ctaBandAria')}>
<div className="kxp-wrap">
<div className="kxp-ctaband">
<span className="kxp-ctaband__text">{t('track.business.ctaBandText')}</span>
<div className="kxp-hero__actions" style={{ margin: 0 }}>
<Link className="kxp-btn kxp-btn--primary kxp-btn--lg" to="/login">
{t('track.business.ctaLogin')}
</Link>
<Link className="kxp-btn kxp-btn--outline kxp-btn--lg" to="/public/exhibit-inquiry">
{t('track.business.ctaInquiry')}
</Link>
</div>
</div>
</div>
</section>
</PublicShell>
);
}

View File

@ -0,0 +1,111 @@
/*
* SCR-T0 (§3C) `/`, .
* + 3- + / .
* 데이터: GET /api/public/events() · 3- = . i18n: track.gateway.* / track.shell.*
*/
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { usePublicEvents, EventGrid } from './trackShared';
import {
IconSparkles,
IconChevronRight,
IconQr,
IconVenue,
IconFactory,
IconArrowRight,
} from './publicIcons';
import type { ComponentType, SVGProps } from 'react';
type Ico = ComponentType<SVGProps<SVGSVGElement>>;
/** 3-트랙 분기 카드 메타 — 관람(1순위·강조) · 비즈니스 · 협력사. */
const TRACK_CARDS: { key: string; icon: Ico; to: string; primary?: boolean }[] = [
{ key: 'visitor', icon: IconQr, to: '/visitor', primary: true },
{ key: 'business', icon: IconVenue, to: '/business' },
{ key: 'agency', icon: IconFactory, to: '/agency' },
];
export function PublicGatewayPage() {
const { t } = useTranslation();
const events = usePublicEvents(8);
return (
<PublicShell active="events" cta={t('track.gateway.ctaPreReg')}>
{/* 제품 히어로 */}
<section className="kxp-hero" aria-label={t('track.gateway.heroAria')}>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #12233b 0%, #1f3a5f 55%, #0066b3 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<span className="kxp-aiwm">{t('track.gateway.aiWatermark')}</span>
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">
<IconSparkles width={16} height={16} /> {t('track.gateway.eyebrow')}
</span>
<h1 className="kxp-hero__title">{t('track.gateway.title')}</h1>
<p className="kxp-hero__lead">{t('track.gateway.subtitle')}</p>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
{t('track.gateway.ctaPreReg')}
</a>
<Link className="kxp-btn kxp-btn--ghost kxp-btn--lg" to="/public/exhibit-inquiry">
{t('track.gateway.ctaInquiry')}
</Link>
</div>
</div>
</div>
</section>
{/* 3-트랙 대형 분기 카드 */}
<section className="kxp-section" id="tracks" aria-label={t('track.gateway.tracksAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.gateway.tracksTitle')}</h2>
<p className="kxp-section__sub">{t('track.gateway.tracksSub')}</p>
</div>
</div>
<div className="kxp-trackcards">
{TRACK_CARDS.map(({ key, icon: Icon, to, primary }) => (
<article
key={key}
className={`kxp-trackcard${primary ? ' kxp-trackcard--primary' : ''}`}
>
<span className="kxp-trackcard__ic" aria-hidden>
<Icon width={26} height={26} />
</span>
<span className="kxp-trackcard__label">{t(`track.gateway.card.${key}.label`)}</span>
<h3 className="kxp-trackcard__title">{t(`track.gateway.card.${key}.title`)}</h3>
<p className="kxp-trackcard__desc">{t(`track.gateway.card.${key}.desc`)}</p>
<Link className="kxp-btn kxp-btn--primary kxp-trackcard__cta" to={to}>
{t(`track.gateway.card.${key}.cta`)} <IconChevronRight width={16} height={16} />
</Link>
</article>
))}
</div>
</div>
</section>
{/* 행사 하이라이트 레일 */}
<section className="kxp-section kxp-section--alt" id="events" aria-label={t('track.gateway.eventsAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.gateway.eventsTitle')}</h2>
<p className="kxp-section__sub">{t('track.gateway.eventsSub')}</p>
</div>
<Link className="kxp-section__more" to="/visitor">
{t('track.gateway.eventsMore')} <IconArrowRight width={16} height={16} />
</Link>
</div>
<EventGrid state={events} />
</div>
</section>
</PublicShell>
);
}

View File

@ -2,11 +2,12 @@
* GNB + . (AppShell , ) * GNB + . (AppShell , )
* design.md §3B: 전통 + SEO· (react-i18next LanguageSwitcher). * design.md §3B: 전통 + SEO· (react-i18next LanguageSwitcher).
*/ */
import { useState, type ReactNode } from 'react'; import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { IconMenu, IconClose, IconSearch } from './publicIcons'; import { IconMenu, IconClose, IconSearch } from './publicIcons';
import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher'; import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher';
import { useAuthStore } from '../../store/authStore';
import './public.css'; import './public.css';
const GNB = [ const GNB = [
@ -16,10 +17,96 @@ const GNB = [
{ key: 'transport', i18n: 'shell.nav.transport' }, { key: 'transport', i18n: 'shell.nav.transport' },
] as const; ] as const;
/** 3-트랙 진입 레이어 키 (§3C). 관문(SCR-T0)은 미선택 → undefined. */
export type PublicTrack = 'visitor' | 'business' | 'agency';
const TRACK_TABS: { key: PublicTrack; to: string; i18n: string }[] = [
{ key: 'visitor', to: '/visitor', i18n: 'track.shell.tab.visitor' },
{ key: 'business', to: '/business', i18n: 'track.shell.tab.business' },
{ key: 'agency', to: '/agency', i18n: 'track.shell.tab.agency' },
];
/**
* 3- (§3C-0). role=tablist· ·/Home/End .
* (SCR-T0) track (), (T1~T3) (aria-current).
*/
function TrackSwitcher({ track }: { track?: PublicTrack }) {
const { t } = useTranslation();
const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const refs = useRef<(HTMLAnchorElement | null)[]>([]);
// 로빙 탭인덱스 기준 인덱스(활성 탭·없으면 첫 탭).
const activeIdx = TRACK_TABS.findIndex((it) => it.key === track);
const baseIdx = activeIdx >= 0 ? activeIdx : 0;
function onKeyDown(e: KeyboardEvent<HTMLAnchorElement>, i: number) {
let next = i;
if (e.key === 'ArrowRight') next = (i + 1) % TRACK_TABS.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + TRACK_TABS.length) % TRACK_TABS.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = TRACK_TABS.length - 1;
else return;
e.preventDefault();
refs.current[next]?.focus();
}
return (
<div className="kxp-topbar">
<div className="kxp-topbar__inner">
<Link className="kxp-wordmark" to="/" aria-label={t('track.shell.wordmark')}>
{t('track.shell.wordmark')}
</Link>
<div className="kxp-tracktabs" role="tablist" aria-label={t('track.shell.tablistAria')}>
{TRACK_TABS.map((it, i) => {
const isActive = it.key === track;
return (
<Link
key={it.key}
ref={(el) => {
refs.current[i] = el;
}}
to={it.to}
role="tab"
className={`kxp-tracktab${isActive ? ' is-active' : ''}`}
aria-selected={isActive}
aria-current={isActive ? 'page' : undefined}
tabIndex={i === baseIdx ? 0 : -1}
onKeyDown={(e) => onKeyDown(e, i)}
>
{t(it.i18n)}
</Link>
);
})}
</div>
<div className="kxp-topbar__utils">
<LanguageSwitcher variant="light" className="kxp-lang__btn" />
{isAuthenticated ? (
<button
type="button"
className="kxp-topbar__link"
onClick={() => navigate('/home')}
>
{t('track.shell.myHome')}
</button>
) : (
<Link className="kxp-topbar__link" to="/login">
{t('track.shell.login')}
</Link>
)}
</div>
</div>
</div>
);
}
interface PublicShellProps { interface PublicShellProps {
children: ReactNode; children: ReactNode;
/** 현재 활성 GNB 키 (하이라이트) */ /** 현재 활성 GNB 키 (하이라이트) */
active?: string; active?: string;
/** 현재 트랙 (§3C — 상단 스위처 활성 탭). 관문은 미지정. */
track?: PublicTrack;
/** 헤더 우측 주요 CTA 라벨 (없으면 사전등록 기본) */ /** 헤더 우측 주요 CTA 라벨 (없으면 사전등록 기본) */
cta?: string; cta?: string;
/** 헤더에 검색 노출 여부 */ /** 헤더에 검색 노출 여부 */
@ -31,6 +118,7 @@ interface PublicShellProps {
export function PublicShell({ export function PublicShell({
children, children,
active, active,
track,
cta, cta,
search = true, search = true,
brand = 'KINTEX', brand = 'KINTEX',
@ -41,6 +129,7 @@ export function PublicShell({
return ( return (
<div className="kxp"> <div className="kxp">
<TrackSwitcher track={track} />
<header className="kxp-header"> <header className="kxp-header">
<div className="kxp-header__inner"> <div className="kxp-header__inner">
<div className="kxp-header__left"> <div className="kxp-header__left">

View File

@ -0,0 +1,180 @@
/*
* SCR-T1 visitor (§3C) `/visitor`, · track="visitor".
* + + (2×3) + ·.
* : /api/public/events() · /api/public/cms/NOTICE() · =.
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell';
import { usePublicEvents, EventGrid } from './trackShared';
import { publicApi, type PublicNotice } from './publicApi';
import { errorMessage } from './publicFormat';
import {
IconTrain,
IconParking,
IconMap,
IconInfo,
IconShield,
IconChat,
IconChevronRight,
IconArrowRight,
IconBell,
} from './publicIcons';
import type { ComponentType, SVGProps } from 'react';
type Ico = ComponentType<SVGProps<SVGSVGElement>>;
/** 방문 가이드 허브 카드(2×3). link 가 내부 경로면 Link, 아니면 앵커. */
const GUIDE_CARDS: { key: string; icon: Ico; to: string }[] = [
{ key: 'transport', icon: IconTrain, to: '#transport' },
{ key: 'parking', icon: IconParking, to: '#transport' },
{ key: 'wayfinding', icon: IconMap, to: '/login' },
{ key: 'amenities', icon: IconInfo, to: '#guide' },
{ key: 'accessibility', icon: IconShield, to: '#guide' },
{ key: 'inquiry', icon: IconChat, to: '/public/exhibit-inquiry' },
];
function GuideLink({ to, children }: { to: string; children: React.ReactNode }) {
if (to.startsWith('#')) {
return (
<a className="kxp-guide__link" href={to}>
{children}
</a>
);
}
return (
<Link className="kxp-guide__link" to={to}>
{children}
</Link>
);
}
export function VisitorTrackPage() {
const { t } = useTranslation();
const events = usePublicEvents(8);
const [notices, setNotices] = useState<PublicNotice[]>([]);
const [noticeErr, setNoticeErr] = useState('');
const [noticeLoading, setNoticeLoading] = useState(true);
useEffect(() => {
let alive = true;
setNoticeLoading(true);
publicApi
.listNotices(5)
.then((list) => alive && (setNotices(list), setNoticeErr('')))
.catch((e) => alive && setNoticeErr(errorMessage(e)))
.finally(() => alive && setNoticeLoading(false));
return () => {
alive = false;
};
}, []);
return (
<PublicShell active="visit" track="visitor" cta={t('track.visitor.ctaPreReg')}>
{/* 관람 히어로 + 이중 CTA */}
<section className="kxp-hero" aria-label={t('track.visitor.heroAria')}>
<div
className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #0b2a4a 0%, #0066b3 70%, #2f8fd6 130%)' }}
aria-hidden
/>
<div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner">
<div className="kxp-hero__content">
<span className="kxp-hero__eyebrow">
<IconBell width={16} height={16} /> {t('track.visitor.eyebrow')}
</span>
<h1 className="kxp-hero__title">{t('track.visitor.title')}</h1>
<p className="kxp-hero__lead">{t('track.visitor.subtitle')}</p>
<div className="kxp-hero__actions">
<a className="kxp-btn kxp-btn--primary kxp-btn--lg" href="#events">
{t('track.visitor.ctaPreReg')}
</a>
<Link className="kxp-btn kxp-btn--ghost kxp-btn--lg" to="/tickets/lookup">
{t('track.visitor.ctaTicket')}
</Link>
</div>
</div>
</div>
</section>
{/* 행사 일정 그리드 */}
<section className="kxp-section" id="events" aria-label={t('track.visitor.eventsAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.eventsTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.eventsSub')}</p>
</div>
</div>
<EventGrid state={events} />
</div>
</section>
{/* 방문 가이드 허브 2×3 */}
<section className="kxp-section kxp-section--alt" id="guide" aria-label={t('track.visitor.guideAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.guideTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.guideSub')}</p>
</div>
</div>
<div className="kxp-guidegrid" id="transport">
{GUIDE_CARDS.map(({ key, icon: Icon, to }) => (
<article key={key} className="kxp-guide">
<span className="kxp-guide__ic" aria-hidden>
<Icon width={22} height={22} />
</span>
<div>
<div className="kxp-guide__title">{t(`track.visitor.guide.${key}.title`)}</div>
<p className="kxp-guide__desc">{t(`track.visitor.guide.${key}.desc`)}</p>
<GuideLink to={to}>
{t(`track.visitor.guide.${key}.link`)} <IconChevronRight width={15} height={15} />
</GuideLink>
</div>
</article>
))}
</div>
</div>
</section>
{/* 공지·알림마당 */}
<section className="kxp-section" id="notices" aria-label={t('track.visitor.noticeAria')}>
<div className="kxp-wrap">
<div className="kxp-section__head">
<div>
<h2 className="kxp-section__title">{t('track.visitor.noticeTitle')}</h2>
<p className="kxp-section__sub">{t('track.visitor.noticeSub')}</p>
</div>
</div>
{noticeLoading && (
<p className="kxp-empty" role="status">
{t('home.loading')}
</p>
)}
{!noticeLoading && (noticeErr || notices.length === 0) && (
<p className="kxp-empty">{t('track.visitor.noticeEmpty')}</p>
)}
{!noticeLoading && !noticeErr && notices.length > 0 && (
<div className="kxp-list">
{notices.map((n) => (
<div key={n.id} className="kxp-listrow">
<div className="kxp-listrow__main">
<div className="kxp-listrow__title">{n.title}</div>
<div className="kxp-listrow__meta">{n.publishedAt ?? n.createdAt ?? ''}</div>
</div>
<span className="kxp-listrow__right">
<IconArrowRight width={18} height={18} />
</span>
</div>
))}
</div>
)}
</div>
</section>
</PublicShell>
);
}

View File

@ -3,6 +3,12 @@
* . . * . .
*/ */
export { PublicShell } from './PublicShell'; export { PublicShell } from './PublicShell';
export type { PublicTrack } from './PublicShell';
// §3C 3-트랙 관문·서브홈 (SCR-T0~T3) — 얕은 트랙 진입 레이어
export { PublicGatewayPage } from './PublicGatewayPage'; // SCR-T0 /
export { VisitorTrackPage } from './VisitorTrackPage'; // SCR-T1 /visitor
export { BusinessTrackPage } from './BusinessTrackPage'; // SCR-T2 /business
export { AgencyTrackPage } from './AgencyTrackPage'; // SCR-T3 /agency
export { PublicHomePage } from './PublicHomePage'; // SCR-P1 /public export { PublicHomePage } from './PublicHomePage'; // SCR-P1 /public
export { PublicEventDetailPage } from './PublicEventDetailPage'; // SCR-P2 /public/events/:eventId export { PublicEventDetailPage } from './PublicEventDetailPage'; // SCR-P2 /public/events/:eventId
export { PublicFloorplanPage } from './PublicFloorplanPage'; // SCR-P3 /public/events/:eventId/floorplan export { PublicFloorplanPage } from './PublicFloorplanPage'; // SCR-P3 /public/events/:eventId/floorplan

View File

@ -2582,10 +2582,12 @@
.kxp-ticktable th { .kxp-ticktable th {
text-align: left; text-align: left;
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 700; font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-neutral-500); color: var(--color-neutral-500);
padding: 12px 8px; padding: 12px 8px;
background: var(--color-neutral-050); background: var(--color-neutral-050);
border-bottom: 2px solid var(--color-neutral-200);
} }
.kxp-ticktable td { .kxp-ticktable td {
padding: 14px 8px; padding: 14px 8px;
@ -2787,3 +2789,424 @@
z-index: 55; z-index: 55;
box-shadow: var(--shadow-level2); box-shadow: var(--shadow-level2);
} }
/* ===================================================================
* §3C 3-트랙 관문·서브홈 (SCR-T0~T3) 상단 스위처 + 트랙 페이지 헬퍼
* 신규 토큰 0 (기존 --color-*/--radius-*/--kxp-maxw 재사용). WCAG AA.
* =================================================================== */
/* -- 상단 3-트랙 스위처 바 (전 SCR-T*/SCR-P* 공통 크롬) -- */
.kxp-topbar {
position: sticky;
top: 0;
z-index: 60;
background: var(--color-primary-700, #004c86);
color: #fff;
}
.kxp-topbar__inner {
max-width: var(--kxp-maxw);
margin: 0 auto;
min-height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 16px;
}
.kxp-wordmark {
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
color: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.kxp-wordmark:hover {
color: #fff;
opacity: 0.9;
}
.kxp-tracktabs {
display: flex;
align-items: stretch;
gap: 4px;
flex: 1;
min-width: 0;
}
.kxp-tracktab {
display: inline-flex;
align-items: center;
padding: 12px 14px;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
border-bottom: 3px solid transparent;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.kxp-tracktab:hover {
color: #fff;
}
.kxp-tracktab.is-active {
color: #fff;
font-weight: 800;
border-bottom-color: #fff;
}
.kxp-tracktab:focus-visible {
outline: 2px solid #fff;
outline-offset: -2px;
border-radius: 2px;
}
.kxp-topbar__utils {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
color: #fff;
}
.kxp-topbar__utils .kxp-lang__btn {
color: #fff;
}
.kxp-topbar__link {
background: none;
border: none;
padding: 4px 2px;
font: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.kxp-topbar__link:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.kxp-topbar__inner {
flex-wrap: wrap;
padding: 6px 12px;
gap: 8px;
}
.kxp-wordmark {
order: 1;
}
.kxp-topbar__utils {
order: 2;
margin-left: auto;
}
.kxp-tracktabs {
order: 3;
flex-basis: 100%;
}
.kxp-tracktab {
flex: 1;
justify-content: center;
padding: 10px 6px;
}
}
/* -- 트랙 관문 대형 분기 카드 (SCR-T0) -- */
.kxp-trackcards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.kxp-trackcard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 28px 24px;
background: #fff;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg, 12px);
box-shadow: var(--shadow-level1);
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.kxp-trackcard:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-level2);
border-color: var(--color-primary-300, #7cb8e0);
}
.kxp-trackcard--primary {
border-color: var(--color-primary-600, #0066b3);
border-width: 2px;
}
.kxp-trackcard__ic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: var(--radius-md, 10px);
background: var(--color-primary-050, #eaf3fb);
color: var(--color-primary-600, #0066b3);
}
.kxp-trackcard__label {
font-size: 13px;
font-weight: 700;
color: var(--color-primary-600, #0066b3);
}
.kxp-trackcard__title {
font-size: var(--fs-h3, 20px);
font-weight: 800;
color: var(--color-neutral-900, #101828);
}
.kxp-trackcard__desc {
font-size: var(--fs-body, 15px);
color: var(--color-neutral-600, #475467);
flex: 1;
}
.kxp-trackcard__cta {
margin-top: 8px;
}
/* -- 방문 가이드 허브 2x3 (SCR-T1) -- */
.kxp-guidegrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.kxp-guide {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 20px;
background: #fff;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-md, 10px);
transition: border-color 0.15s, box-shadow 0.15s;
}
.kxp-guide:hover {
border-color: var(--color-primary-300, #7cb8e0);
box-shadow: var(--shadow-level1);
}
.kxp-guide__ic {
display: inline-flex;
flex-shrink: 0;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm, 8px);
background: var(--color-primary-050, #eaf3fb);
color: var(--color-primary-600, #0066b3);
}
.kxp-guide__title {
font-size: var(--fs-body, 15px);
font-weight: 700;
color: var(--color-neutral-900, #101828);
margin-bottom: 2px;
}
.kxp-guide__desc {
font-size: var(--fs-caption, 13px);
color: var(--color-neutral-600, #475467);
}
.kxp-guide__link {
display: inline-flex;
align-items: center;
gap: 2px;
margin-top: 6px;
font-size: 13px;
font-weight: 700;
color: var(--color-primary-600, #0066b3);
}
/* -- 비즈니스 2-분기 가치 카드 (SCR-T2) -- */
.kxp-valuegrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.kxp-value {
display: flex;
flex-direction: column;
gap: 12px;
padding: 28px;
background: #fff;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-lg, 12px);
box-shadow: var(--shadow-level1);
}
.kxp-value__head {
display: flex;
align-items: center;
gap: 12px;
}
.kxp-value__ic {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
border-radius: var(--radius-md, 10px);
background: var(--color-primary-050, #eaf3fb);
color: var(--color-primary-600, #0066b3);
}
.kxp-value__title {
font-size: var(--fs-h3, 20px);
font-weight: 800;
color: var(--color-neutral-900, #101828);
}
.kxp-value__list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.kxp-value__li {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--fs-body, 15px);
color: var(--color-neutral-700, #344054);
}
.kxp-value__li svg {
flex-shrink: 0;
color: var(--color-primary-600, #0066b3);
}
.kxp-aichip {
display: inline-flex;
align-items: center;
padding: 1px 7px;
font-size: 11px;
font-weight: 800;
border-radius: var(--radius-pill, 9999px);
background: #efeaff;
color: var(--color-ai-accent, #6d4aff);
}
/* -- 절차 스텝 (SCR-T2/T3) -- */
.kxp-flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.kxp-flow__step {
flex: 1;
min-width: 150px;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
background: var(--color-neutral-050, #f9fafb);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-md, 10px);
}
.kxp-flow__no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 9999px;
background: var(--color-primary-600, #0066b3);
color: #fff;
font-size: 12px;
font-weight: 800;
}
.kxp-flow__title {
font-size: var(--fs-body, 15px);
font-weight: 700;
color: var(--color-neutral-900, #101828);
}
.kxp-flow__desc {
font-size: var(--fs-caption, 13px);
color: var(--color-neutral-600, #475467);
}
/* -- 안내/고지 카드 (SCR-T3 등록 게이트/안전) -- */
.kxp-notice-card {
display: flex;
gap: 14px;
padding: 18px 20px;
border-radius: var(--radius-md, 10px);
border: 1px solid var(--color-neutral-200);
background: #fff;
}
.kxp-notice-card--warn {
border-color: #f5c98a;
background: #fffaf2;
}
.kxp-notice-card__ic {
flex-shrink: 0;
color: var(--color-warning, #b45309);
}
.kxp-notice-card__title {
font-size: var(--fs-body, 15px);
font-weight: 800;
color: var(--color-warning, #b45309);
margin-bottom: 4px;
}
.kxp-notice-card__body {
font-size: var(--fs-caption, 13px);
color: var(--color-neutral-700, #344054);
}
/* -- 공지/옥션 리스트 행 (SCR-T1/T3) -- */
.kxp-list {
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-md, 10px);
overflow: hidden;
background: #fff;
}
.kxp-listrow {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-neutral-100);
text-align: left;
}
.kxp-listrow:last-child {
border-bottom: none;
}
.kxp-listrow:hover {
background: var(--color-neutral-050, #f9fafb);
}
.kxp-listrow__main {
flex: 1;
min-width: 0;
}
.kxp-listrow__title {
font-size: var(--fs-body, 15px);
font-weight: 700;
color: var(--color-neutral-900, #101828);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kxp-listrow__meta {
font-size: var(--fs-caption, 13px);
color: var(--color-neutral-500, #667085);
margin-top: 2px;
}
.kxp-listrow__right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
/* 트랙 CTA 밴드 */
.kxp-ctaband {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 28px;
border-radius: var(--radius-lg, 12px);
background: var(--color-primary-050, #eaf3fb);
}
.kxp-ctaband__text {
font-size: var(--fs-h3, 20px);
font-weight: 800;
color: var(--color-primary-800, #003a66);
}

View File

@ -40,6 +40,15 @@ export interface PublicFloorplan {
booths: PublicFloorplanBooth[]; booths: PublicFloorplanBooth[];
} }
// ── 공개 공지 (M17 CMS · GET /api/public/cms/NOTICE — 게시분만) ──────────────
export interface PublicNotice {
id: string;
title: string;
body?: string | null;
publishedAt?: string | null;
createdAt?: string | null;
}
// ── 문의 접수 (백엔드 InquiryRequest / InquiryReceiptDto) ──────────────────── // ── 문의 접수 (백엔드 InquiryRequest / InquiryReceiptDto) ────────────────────
export interface InquiryPayload { export interface InquiryPayload {
inquiryType?: string; inquiryType?: string;
@ -121,4 +130,8 @@ export const publicApi = {
/** cms 팀 계약 — shape 확정 전 관용 처리. */ /** cms 팀 계약 — shape 확정 전 관용 처리. */
getMicrosite: (exhibitorId: string) => getMicrosite: (exhibitorId: string) =>
api.get<PublicMicrosite>(`/api/public/microsites/${encodeURIComponent(exhibitorId)}`, opt), api.get<PublicMicrosite>(`/api/public/microsites/${encodeURIComponent(exhibitorId)}`, opt),
/** M17 공개 공지(게시분) — 최근순. 백엔드 미배포 시 에러(빈/안내로 처리). */
listNotices: (limit = 5) =>
api.get<PublicNotice[]>(`/api/public/cms/NOTICE${qs({ limit: String(limit) })}`, opt),
}; };

View File

@ -0,0 +1,137 @@
/*
* §3C 3-(SCR-T0~T3) + .
* SCR-P1(PublicHomePage) ·publicApi·publicFormat·publicIcons .
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { publicApi, type PublicEvent } from './publicApi';
import { deriveStatus, dday, formatRange, errorMessage } from './publicFormat';
import { IconCalendar, IconPin } from './publicIcons';
export function ddayClass(d: number) {
if (d <= 3) return 'kxp-dday kxp-dday--soon';
if (d <= 7) return 'kxp-dday kxp-dday--warn';
return 'kxp-dday';
}
/** 진행중·예정 우선 정렬 후 상위 N건. */
export function pickFeatured(events: PublicEvent[], max = 8): PublicEvent[] {
const rank = (e: PublicEvent) => {
const s = deriveStatus(e.startDate, e.endDate);
return s === 'ongoing' ? 0 : s === 'upcoming' ? 1 : 2;
};
return [...events].sort((a, b) => rank(a) - rank(b)).slice(0, max);
}
export interface EventsState {
events: PublicEvent[];
loading: boolean;
error: string;
}
/** 공개 행사 카탈로그 로드(비인증). */
export function usePublicEvents(max = 8): EventsState {
const [events, setEvents] = useState<PublicEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
setLoading(true);
publicApi
.listEvents()
.then((list) => {
if (alive) {
setEvents(pickFeatured(list, max));
setError('');
}
})
.catch((e) => alive && setError(errorMessage(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [max]);
return { events, loading, error };
}
/** 행사 카드 그리드 — SCR-P1 카드 자산 재사용. 빈/로딩/에러 인라인 처리. */
export function EventGrid({ state }: { state: EventsState }) {
const { t } = useTranslation();
const { events, loading, error } = state;
if (loading) {
return (
<p className="kxp-empty" role="status">
{t('home.loading')}
</p>
);
}
if (error) {
return (
<p className="kxp-empty" role="alert" style={{ color: 'var(--color-error)' }}>
{error}
</p>
);
}
if (events.length === 0) {
return <p className="kxp-empty">{t('home.empty')}</p>;
}
return (
<div className="kxp-eventgrid">
{events.map((ev) => {
const status = deriveStatus(ev.startDate, ev.endDate);
const d = dday(ev.startDate);
return (
<article key={ev.id} className="kxp-ecard">
<div className="kxp-ecard__media">
<div
style={{
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #0066b3 0%, #004c86 100%)',
}}
aria-hidden
/>
{status === 'ongoing' ? (
<span className="kxp-ecard__botleft kxp-dday kxp-dday--soon">{t('home.ongoing')}</span>
) : status === 'ended' ? (
<span className="kxp-ecard__botleft kxp-dday">{t('home.ended')}</span>
) : (
<span className={`kxp-ecard__botleft ${ddayClass(d)}`}>
{d === 0 ? t('home.ddayZero') : t('home.dday', { n: d })}
</span>
)}
</div>
<div className="kxp-ecard__body">
<div className="kxp-ecard__tags">
<span className="kxp-tag">{t('home.tagExhibition')}</span>
{ev.boothCount != null && ev.boothCount > 0 && (
<span className="kxp-tag">{t('home.booth', { count: ev.boothCount })}</span>
)}
</div>
<h3 className="kxp-ecard__title">{ev.name}</h3>
<div className="kxp-ecard__meta">
<span>
<IconCalendar width={18} height={18} /> {formatRange(ev.startDate, ev.endDate)}
</span>
<span>
<IconPin width={18} height={18} /> {ev.hallLabel ?? t('home.hallTbd')}
</span>
</div>
<Link
className="kxp-btn kxp-btn--primary kxp-btn--block"
to={`/public/events/${encodeURIComponent(ev.id)}/register`}
>
{t('home.register')}
</Link>
</div>
</article>
);
})}
</div>
);
}

View File

@ -9,8 +9,6 @@ import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { import {
IconCalendar, IconCalendar,
IconChevronLeft,
IconChevronRight,
IconGrid, IconGrid,
IconUsers, IconUsers,
} from '../../components/ui/icons'; } from '../../components/ui/icons';
@ -20,17 +18,37 @@ import {
EVENT_YEARS, EVENT_YEARS,
STATUS_LABEL, STATUS_LABEL,
eventDaysFor, eventDaysFor,
eventsByDateFor,
hallUtilizationFor, hallUtilizationFor,
type EventCategory, type EventCategory,
type EventStatus, type EventStatus,
type HallUtilRow, type HallUtilRow,
type ScheduleEvent, type ScheduleEvent,
} from './sampleSchedule'; } from './sampleSchedule';
import KxCalendarView, { type KxCalendarItem } from './KxCalendarView';
import { scheduleApi } from './scheduleApi'; import { scheduleApi } from './scheduleApi';
import { hallAssignApi } from '../hallassign/hallAssignApi'; import { hallAssignApi } from '../hallassign/hallAssignApi';
import './schedule.css'; import './schedule.css';
/** 카테고리별 막대/칩 강조색(schedule.css chip 색과 정렬). */
const CAT_COLOR: Record<string, string> = {
exhibition: '#0066b3',
culture: '#b83268',
trade: '#9a6b1f',
tech: '#1f7a54',
};
/** ScheduleEvent → KxCalendarItem(기간 막대·상태 색점). */
function toCalItem(e: ScheduleEvent): KxCalendarItem {
return {
id: String(e.id),
startDate: e.start,
endDate: e.end,
title: e.title,
color: CAT_COLOR[e.category] ?? '#0066b3',
statusCd: e.status === 'ongoing' ? 'ONGOING' : e.status === 'upcoming' ? 'UPCOMING' : 'DONE',
};
}
/** 집계 API 실패 시 크롤링 샘플로 강등할지 판정(NETWORK/NOT_FOUND/NOT_IMPLEMENTED). */ /** 집계 API 실패 시 크롤링 샘플로 강등할지 판정(NETWORK/NOT_FOUND/NOT_IMPLEMENTED). */
function isDegradable(err: unknown): boolean { function isDegradable(err: unknown): boolean {
return ( return (
@ -149,6 +167,18 @@ export function ExhibitionSchedulePage() {
// 목록은 최신 시작일 우선 정렬 // 목록은 최신 시작일 우선 정렬
const listed = useMemo(() => [...filtered].sort((a, b) => (a.start < b.start ? 1 : -1)), [filtered]); const listed = useMemo(() => [...filtered].sort((a, b) => (a.start < b.start ? 1 : -1)), [filtered]);
// 캘린더(월/주/일) 항목 — 필터된 행사(기간=start~end) 매핑.
const calItems = useMemo(() => filtered.map(toCalItem), [filtered]);
const catLegend = useMemo(
() =>
Object.keys(CATEGORY_LABEL).map((k) => ({
code: k,
label: t(`schedule.cat.${k}`),
color: CAT_COLOR[k],
})),
[t],
);
return ( return (
<div className="kx-sched"> <div className="kx-sched">
<header className="kx-sched__head"> <header className="kx-sched__head">
@ -252,7 +282,12 @@ export function ExhibitionSchedulePage() {
)} )}
</section> </section>
) : ( ) : (
<MonthCalendar events={filtered} /> <KxCalendarView
items={calItems}
legend={catLegend}
today={TODAY_YMD}
initialDate={TODAY_YMD}
/>
)} )}
<aside className="kx-sched__aside"> <aside className="kx-sched__aside">
@ -266,78 +301,6 @@ export function ExhibitionSchedulePage() {
} }
const WD = ['일', '월', '화', '수', '목', '금', '토']; const WD = ['일', '월', '화', '수', '목', '금', '토'];
const MAX_CHIPS = 3; // 월 그리드 셀당 표시 칩 수 (초과분 +N)
/**
* WISE(UIWS ScheduleCalendar) // , ,
* , ( ), +N. .
*/
function MonthCalendar({ events }: { events: ScheduleEvent[] }) {
const { t } = useTranslation();
const [cursor, setCursor] = useState(new Date(2026, 6, 1)); // 2026년 7월
const year = cursor.getFullYear();
const month = cursor.getMonth();
const byDate = useMemo(() => eventsByDateFor(year, month, events), [year, month, events]);
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (number | null)[] = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const todayKey =
TODAY.getFullYear() === year && TODAY.getMonth() === month ? TODAY.getDate() : -1;
const mm = String(month + 1).padStart(2, '0');
return (
<section className="kx-card kx-mcal" aria-label={t('schedule.monthCal')}>
<div className="kx-mcal__toolbar">
<button type="button" className="kx-mcal__nav" onClick={() => setCursor(new Date(year, month - 1, 1))}>
<IconChevronLeft size={14} /> {t('schedule.prev')}
</button>
<strong className="kx-mcal__title tnum">{t('schedule.yearMonth', { y: year, m: month + 1 })}</strong>
<button type="button" className="kx-mcal__nav" onClick={() => setCursor(new Date(year, month + 1, 1))}>
{t('schedule.next')} <IconChevronRight size={14} />
</button>
<button type="button" className="kx-mcal__nav" onClick={() => setCursor(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1))}>
{t('schedule.today')}
</button>
</div>
<div className="kx-mcal__grid">
{WD.map((w, i) => (
<div key={w} className={`kx-mcal__dow${i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : ''}`}>{t(`common.weekday.${i}`)}</div>
))}
{cells.map((d, i) => {
if (d === null) return <div key={`e${i}`} className="kx-mcal__cell is-out" />;
const key = `${year}-${mm}-${String(d).padStart(2, '0')}`;
const dayEvents = byDate.get(key) ?? [];
const dow = i % 7;
const cls = [
'kx-mcal__cell',
d === todayKey ? 'is-today' : '',
dow === 0 ? 'is-sun' : dow === 6 ? 'is-sat' : '',
]
.filter(Boolean)
.join(' ');
return (
<div key={key} className={cls}>
<span className="kx-mcal__daynum tnum">{d}</span>
{dayEvents.slice(0, MAX_CHIPS).map((e) => (
<span key={e.id} className={`kx-mcal__chip is-${e.category}`} title={`${e.title} (${e.halls})`}>
{e.title}
</span>
))}
{dayEvents.length > MAX_CHIPS && (
<span className="kx-mcal__more tnum">+{dayEvents.length - MAX_CHIPS}</span>
)}
</div>
);
})}
</div>
</section>
);
}
function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date) => void }) { function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@ -0,0 +1,685 @@
// UIWS CalendarView 정본 구조 이식(월/주/일 뷰 전환·기간 막대·+N 더보기).
// 정본: workspace/uiws/frontend/src/components/CalendarView.tsx + calendarView.css.
// kintex 이식: 클래스/토큰만 kx-cv-* + kintex 디자인 토큰으로 번역. 외부 캘린더 라이브러리 미사용.
// 데이터는 controlled `items`(부모가 range별 조회) + onRangeChange 로 기존 데이터 소스 유지.
// 코드값 훅(useCodeValues)·공용 Modal 의존 없이 자체 경량 구현(kx 토큰).
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { IconChevronLeft, IconChevronRight, IconClose } from '../../components/ui/icons';
import './calendarView.css';
export interface KxCalendarItem {
/** 안정 키. 부모에서 문자열로 정규화. */
id: string;
/** 기간 시작일(yyyy-MM-dd) */
startDate: string;
/** 기간 종료일(yyyy-MM-dd, 포함). 미지정/시작일과 동일 → 단일일. */
endDate?: string;
title: string;
/** 막대/칩 강조색(hex) — 유형별 색(work 일정 타입 등). 미지정 시 기본 primary. */
color?: string;
/** 소형 텍스트 뱃지(유형 라벨 등) */
badge?: string;
/** 진행상태 색점 코드(UPCOMING/ONGOING/DONE). */
statusCd?: string;
}
export interface KxCalendarLegendItem {
code: string;
label: string;
color?: string;
}
export type KxCalendarRange = { fromDate: string; toDate: string };
type View = 'month' | 'week' | 'day';
interface KxCalendarViewProps {
/** 부모가 현재 range 로 조회해 넘기는 항목(controlled). */
items: KxCalendarItem[];
/** 뷰/기준일이 바뀌어 표시 범위가 달라질 때 통지(부모가 그 범위로 재조회). */
onRangeChange?: (range: KxCalendarRange, view: View) => void;
/** 빈 날짜(셀) 클릭 */
onDateClick?: (date: string) => void;
/** 항목 클릭 */
onItemClick?: (item: KxCalendarItem) => void;
/** 상태 색점 범례 */
legend?: KxCalendarLegendItem[];
/** 당일(yyyy-MM-dd). 미지정 시 실제 오늘. */
today?: string;
/** 초기 기준일. 미지정 시 today. */
initialDate?: string;
initialView?: View;
/** 외부 선택일 하이라이트(work 어젠다 연동) */
selectedDate?: string | null;
loading?: boolean;
/**
* (yyyy-MM-dd ).
* ( --color-error).
* (undefined) ( ).
*/
holidays?: Map<string, string>;
}
/** 한 주(week-row) 안에서 차지할 수 있는 최대 레인 수(초과 시 "+N"). */
const MAX_LANES = 3;
/** 월 뷰 한 날짜 셀에 표시할 단일일 항목 최대 건수(초과 시 "+N"). */
const MAX_CELL_ITEMS = 4;
/* ===================== 날짜 헬퍼(utils/date 인라인 이식) ===================== */
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function fmt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function realTodayYmd(): string {
return fmt(new Date());
}
function parseYmd(ymd: string): Date {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
function ymdPart(s: string): string {
return (s || '').slice(0, 10);
}
function diffDays(a: string, b: string): number {
return Math.round((parseYmd(b).getTime() - parseYmd(a).getTime()) / 86_400_000);
}
function shiftDays(ymd: string, n: number): string {
const d = parseYmd(ymd);
d.setDate(d.getDate() + n);
return fmt(d);
}
function shiftMonth(ymd: string, n: number): string {
const d = parseYmd(ymd);
return fmt(new Date(d.getFullYear(), d.getMonth() + n, 1));
}
function dayOf(ymd: string): number {
return parseYmd(ymd).getDate();
}
function startOfWeek(ymd: string): string {
const d = parseYmd(ymd);
d.setDate(d.getDate() - d.getDay());
return fmt(d);
}
function weekDates(ymd: string): string[] {
const s = startOfWeek(ymd);
return Array.from({ length: 7 }, (_, i) => shiftDays(s, i));
}
function monthGrid(ymd: string): { date: string; inMonth: boolean }[] {
const base = parseYmd(ymd);
const first = new Date(base.getFullYear(), base.getMonth(), 1);
const start = new Date(first);
start.setDate(1 - first.getDay());
const month = base.getMonth();
return Array.from({ length: 42 }, (_, i) => {
const c = new Date(start);
c.setDate(start.getDate() + i);
return { date: fmt(c), inMonth: c.getMonth() === month };
});
}
/* ===================== 항목 파생 ===================== */
function itemSpan(it: KxCalendarItem): { start: string; end: string } {
const start = ymdPart(it.startDate);
let end = ymdPart(it.endDate ?? it.startDate);
if (!end || diffDays(start, end) < 0) end = start;
return { start, end };
}
function isMultiDay(it: KxCalendarItem): boolean {
const { start, end } = itemSpan(it);
return !!start && diffDays(start, end) >= 1;
}
/** 셀 내 정렬(신규 우선) — id 사전식 desc. */
function newestFirst(a: KxCalendarItem, b: KxCalendarItem): number {
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
}
function rangeOf(view: View, baseDate: string): KxCalendarRange {
if (view === 'month') {
const grid = monthGrid(baseDate);
return { fromDate: grid[0].date, toDate: grid[grid.length - 1].date };
}
if (view === 'week') {
const w = weekDates(baseDate);
return { fromDate: w[0], toDate: w[6] };
}
return { fromDate: baseDate, toDate: baseDate };
}
/* ===================== 기간 막대 레인 배치 ===================== */
interface BarSegment {
item: KxCalendarItem;
startCol: number;
span: number;
lane: number;
continuesLeft: boolean;
continuesRight: boolean;
}
function layoutWeekBars(
cols: string[],
multiItems: KxCalendarItem[],
laneHint: Map<string, number>,
): { segments: BarSegment[]; usedLanes: number } {
const weekStart = cols[0];
const weekEnd = cols[6];
type Pending = Omit<BarSegment, 'lane'>;
const pending: Pending[] = [];
for (const item of multiItems) {
const { start, end } = itemSpan(item);
if (diffDays(end, weekStart) > 0 || diffDays(start, weekEnd) > 0) continue;
const clipStart = diffDays(start, weekStart) > 0 ? weekStart : start;
const clipEnd = diffDays(end, weekEnd) < 0 ? end : weekEnd;
const startCol = diffDays(weekStart, clipStart);
const span = diffDays(clipStart, clipEnd) + 1;
pending.push({
item,
startCol,
span,
continuesLeft: diffDays(start, weekStart) > 0,
continuesRight: diffDays(end, weekEnd) < 0,
});
}
pending.sort((a, b) => a.startCol - b.startCol || b.span - a.span);
const laneMasks: number[] = [];
const maskOf = (startCol: number, span: number) => {
let m = 0;
for (let c = startCol; c < startCol + span; c++) m |= 1 << c;
return m;
};
const fits = (lane: number, mask: number) => ((laneMasks[lane] ?? 0) & mask) === 0;
const segments: BarSegment[] = [];
let usedLanes = 0;
for (const p of pending) {
const mask = maskOf(p.startCol, p.span);
let lane = -1;
const hinted = laneHint.get(p.item.id);
if (hinted != null && fits(hinted, mask)) {
lane = hinted;
} else {
for (let l = 0; ; l++) {
if (fits(l, mask)) {
lane = l;
break;
}
}
}
laneMasks[lane] = (laneMasks[lane] ?? 0) | mask;
laneHint.set(p.item.id, lane);
usedLanes = Math.max(usedLanes, lane + 1);
segments.push({ ...p, lane });
}
return { segments, usedLanes };
}
/* ===================== 본체 ===================== */
export default function KxCalendarView({
items,
onRangeChange,
onDateClick,
onItemClick,
legend,
today,
initialDate,
initialView = 'month',
selectedDate,
loading,
holidays,
}: KxCalendarViewProps) {
const { t } = useTranslation();
const todayYmd = today ?? realTodayYmd();
const [view, setView] = useState<View>(initialView);
const [baseDate, setBaseDate] = useState(initialDate ?? todayYmd);
const [moreDate, setMoreDate] = useState<string | null>(null);
// 표시 범위 변경 통지(부모 재조회).
useEffect(() => {
onRangeChange?.(rangeOf(view, baseDate), view);
// onRangeChange 는 의도적으로 의존성에서 제외(부모가 매 렌더 새 함수 전달 시 루프 방지).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, baseDate]);
const singleByDate = useMemo(() => {
const m = new Map<string, KxCalendarItem[]>();
items.forEach((it) => {
if (isMultiDay(it)) return;
const key = itemSpan(it).start;
if (!key) return;
const arr = m.get(key) ?? [];
arr.push(it);
m.set(key, arr);
});
return m;
}, [items]);
const multiItems = useMemo(() => items.filter(isMultiDay), [items]);
const eventsOnDate = (date: string): KxCalendarItem[] => {
const spanning = multiItems.filter((it) => {
const { start, end } = itemSpan(it);
return diffDays(start, date) >= 0 && diffDays(date, end) >= 0;
});
const singles = [...(singleByDate.get(date) ?? [])].sort(newestFirst);
return [...spanning, ...singles];
};
const move = (delta: number) => {
if (view === 'month') setBaseDate((b) => shiftMonth(b, delta));
else if (view === 'week') setBaseDate((b) => shiftDays(b, delta * 7));
else setBaseDate((b) => shiftDays(b, delta));
};
const title = useMemo(() => {
if (view === 'month') {
const d = parseYmd(baseDate);
return t('calendar.yearMonth', { y: d.getFullYear(), m: d.getMonth() + 1 });
}
if (view === 'week') {
const w = weekDates(baseDate);
return `${w[0]} ~ ${w[6]}`;
}
return baseDate;
}, [view, baseDate, t]);
const renderItem = (it: KxCalendarItem) => (
<button
key={it.id}
type="button"
className={`kx-cv-event${it.statusCd === 'DONE' ? ' is-done' : ''}`}
style={it.color ? { borderLeftColor: it.color } : undefined}
title={it.title}
onClick={(e) => {
e.stopPropagation();
onItemClick?.(it);
}}
>
{it.statusCd && <span className={`kx-cv-dot kx-cv-imp-${it.statusCd}`} aria-hidden="true" />}
{it.badge && <span className="kx-cv-badge">{it.badge}</span>}
<span className="kx-cv-event-title">{it.title}</span>
</button>
);
const renderBar = (seg: BarSegment) => {
const it = seg.item;
const cls = [
'kx-cv-bar',
seg.continuesLeft ? 'is-cont-left' : '',
seg.continuesRight ? 'is-cont-right' : '',
it.statusCd === 'DONE' ? 'is-done' : '',
]
.filter(Boolean)
.join(' ');
return (
<button
key={`${it.id}-${seg.startCol}`}
type="button"
className={cls}
style={{
gridColumn: `${seg.startCol + 1} / span ${seg.span}`,
gridRow: seg.lane + 1,
...(it.color ? { borderLeftColor: it.color } : {}),
}}
title={it.title}
onClick={(e) => {
e.stopPropagation();
onItemClick?.(it);
}}
>
{!seg.continuesLeft && it.badge && <span className="kx-cv-bar-badge">{it.badge}</span>}
<span className="kx-cv-bar-title">{it.title}</span>
</button>
);
};
return (
<div className="kx-cv-root">
<div className="kx-cv-toolbar">
<button type="button" className="kx-cv-nav" onClick={() => move(-1)}>
<IconChevronLeft size={14} /> {t('calendar.prev')}
</button>
<strong className="kx-cv-title tnum" aria-live="polite">
{title}
</strong>
<button type="button" className="kx-cv-nav" onClick={() => move(1)}>
{t('calendar.next')} <IconChevronRight size={14} />
</button>
<button type="button" className="kx-cv-nav" onClick={() => setBaseDate(todayYmd)}>
{t('calendar.today')}
</button>
<span className="kx-cv-switch" role="group" aria-label={t('calendar.viewSwitch')}>
{(['month', 'week', 'day'] as View[]).map((v) => (
<button
key={v}
type="button"
className={view === v ? 'is-active' : ''}
aria-pressed={view === v}
onClick={() => setView(v)}
>
{t(`calendar.view.${v}`)}
</button>
))}
</span>
{loading && <span className="kx-cv-muted">{t('calendar.loading')}</span>}
</div>
{legend && legend.length > 0 && (
<div className="kx-cv-legend" aria-label={t('calendar.legend')}>
{legend.map((c) => (
<span key={c.code} className="kx-cv-legend-item">
<span
className={`kx-cv-dot${c.color ? '' : ` kx-cv-imp-${c.code}`}`}
style={c.color ? { background: c.color } : undefined}
aria-hidden="true"
/>
{c.label}
</span>
))}
</div>
)}
{view === 'month' && (
<MonthView
baseDate={baseDate}
today={todayYmd}
selectedDate={selectedDate ?? null}
singleByDate={singleByDate}
multiItems={multiItems}
onDateClick={onDateClick}
onMore={setMoreDate}
renderItem={renderItem}
renderBar={renderBar}
holidays={holidays}
/>
)}
{view === 'week' && (
<WeekView
baseDate={baseDate}
today={todayYmd}
selectedDate={selectedDate ?? null}
singleByDate={singleByDate}
multiItems={multiItems}
onDateClick={onDateClick}
onMore={setMoreDate}
renderItem={renderItem}
renderBar={renderBar}
holidays={holidays}
/>
)}
{view === 'day' && (
<div
className={`kx-cv-day${holidays?.has(baseDate) ? ' is-holiday' : ''}`}
onClick={() => onDateClick?.(baseDate)}
role={onDateClick ? 'button' : undefined}
>
<div className="kx-cv-day-head">
{baseDate}
{holidays?.get(baseDate) && (
<span className="kx-cv-holiday-name">{holidays.get(baseDate)}</span>
)}
</div>
{(() => {
const all = eventsOnDate(baseDate);
if (all.length === 0) {
return <p className="kx-cv-muted">{t('calendar.dayEmpty')}</p>;
}
return <div className="kx-cv-day-list">{all.map(renderItem)}</div>;
})()}
</div>
)}
{moreDate && (
<div className="kx-cv-modal-backdrop" onClick={() => setMoreDate(null)}>
<div
className="kx-cv-modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<div className="kx-cv-modal-head">
<strong>{t('calendar.dayAll', { date: moreDate })}</strong>
<button
type="button"
className="kx-cv-modal-close"
aria-label={t('calendar.close')}
onClick={() => setMoreDate(null)}
>
<IconClose size={16} />
</button>
</div>
{(() => {
const dayItems = eventsOnDate(moreDate);
if (dayItems.length === 0) {
return <p className="kx-cv-muted">{t('calendar.dayEmpty')}</p>;
}
return (
<ul className="kx-cv-more-list">
{dayItems.map((it) => {
const { start, end } = itemSpan(it);
return (
<li key={it.id}>
<button
type="button"
className="kx-cv-more-item"
onClick={() => {
setMoreDate(null);
onItemClick?.(it);
}}
>
{it.statusCd && (
<span
className={`kx-cv-dot kx-cv-imp-${it.statusCd}`}
aria-hidden="true"
/>
)}
{it.badge && <span className="kx-cv-badge">{it.badge}</span>}
<span className="kx-cv-more-item-title">{it.title}</span>
<span className="kx-cv-more-item-range">
{isMultiDay(it) ? `${start} ~ ${end}` : start}
</span>
</button>
</li>
);
})}
</ul>
);
})()}
</div>
</div>
)}
</div>
);
}
/* ===================== 월 뷰 ===================== */
interface WeekViewShared {
today: string;
selectedDate: string | null;
singleByDate: Map<string, KxCalendarItem[]>;
multiItems: KxCalendarItem[];
onDateClick?: (date: string) => void;
onMore: (date: string) => void;
renderItem: (it: KxCalendarItem) => JSX.Element;
renderBar: (seg: BarSegment) => JSX.Element;
/** 공휴일 세트(ymd → 명칭). 미지정 시 표시 없음. */
holidays?: Map<string, string>;
}
function MonthView({ baseDate, ...rest }: { baseDate: string } & WeekViewShared) {
const { t } = useTranslation();
const grid = monthGrid(baseDate);
const weeks: { date: string; inMonth: boolean }[][] = [];
for (let w = 0; w < grid.length; w += 7) weeks.push(grid.slice(w, w + 7));
const laneHint = new Map<string, number>();
return (
<div className="kx-cv-grid">
<div className="kx-cv-dow-row">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className={`kx-cv-dow${i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : ''}`}>
{t(`common.weekday.${i}`)}
</div>
))}
</div>
{weeks.map((week) => (
<MonthWeekRow key={week[0].date} week={week} laneHint={laneHint} {...rest} />
))}
</div>
);
}
function MonthWeekRow({
week,
laneHint,
today,
selectedDate,
singleByDate,
multiItems,
onDateClick,
onMore,
renderItem,
renderBar,
holidays,
}: {
week: { date: string; inMonth: boolean }[];
laneHint: Map<string, number>;
} & WeekViewShared) {
const cols = week.map((c) => c.date);
const { segments, usedLanes } = layoutWeekBars(cols, multiItems, laneHint);
const lanes = Math.min(usedLanes, MAX_LANES);
const barAreaStyle = { ['--kx-cv-bar-lanes']: String(lanes) } as CSSProperties;
const hiddenBars = segments.filter((s) => s.lane >= MAX_LANES);
return (
<div className="kx-cv-week-row" style={barAreaStyle}>
<div className="kx-cv-week-cells">
{week.map(({ date, inMonth }, dow) => {
const singles = singleByDate.get(date) ?? [];
const holidayName = holidays?.get(date);
const cls = [
'kx-cv-cell',
inMonth ? '' : 'is-out',
date === today ? 'is-today' : '',
date === selectedDate ? 'is-selected' : '',
dow === 0 ? 'is-sun' : dow === 6 ? 'is-sat' : '',
holidayName ? 'is-holiday' : '',
]
.filter(Boolean)
.join(' ');
const hiddenHere = hiddenBars.filter((s) => s.startCol === dow).length;
const ordered = [...singles].sort(newestFirst);
const shown = ordered.slice(0, MAX_CELL_ITEMS);
const moreCount = ordered.length - shown.length + hiddenHere;
return (
<div key={date} className={cls} onClick={() => onDateClick?.(date)}>
<span className="kx-cv-daynum">
{dayOf(date)}
{holidayName && (
<em className="kx-cv-holiday-name" title={holidayName}>
{holidayName}
</em>
)}
</span>
<div className="kx-cv-cell-singles">
{shown.map(renderItem)}
{moreCount > 0 && (
<button
type="button"
className="kx-cv-more"
onClick={(e) => {
e.stopPropagation();
onMore(date);
}}
>
+{moreCount}
</button>
)}
</div>
</div>
);
})}
</div>
{lanes > 0 && (
<div className="kx-cv-bars">{segments.filter((s) => s.lane < MAX_LANES).map(renderBar)}</div>
)}
</div>
);
}
/* ===================== 주 뷰 ===================== */
function WeekView({
baseDate,
today,
selectedDate,
singleByDate,
multiItems,
onDateClick,
onMore,
renderItem,
renderBar,
holidays,
}: { baseDate: string } & WeekViewShared) {
const { t } = useTranslation();
const cols = weekDates(baseDate);
const laneHint = new Map<string, number>();
const { segments, usedLanes } = layoutWeekBars(cols, multiItems, laneHint);
const lanes = Math.min(usedLanes, MAX_LANES);
const hiddenBars = segments.filter((s) => s.lane >= MAX_LANES);
return (
<div className="kx-cv-week-view">
<div className="kx-cv-week-grid">
{cols.map((date, i) => {
const holidayName = holidays?.get(date);
const dowCls = i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : '';
return (
<div key={`h-${date}`} className={`kx-cv-dow${dowCls}${holidayName ? ' is-holiday' : ''}`}>
{t(`common.weekday.${i}`)} {dayOf(date)}
{holidayName && <em className="kx-cv-holiday-name" title={holidayName}>{holidayName}</em>}
</div>
);
})}
</div>
{lanes > 0 && (
<div
className="kx-cv-week-bars"
style={{ ['--kx-cv-bar-lanes']: String(lanes) } as CSSProperties}
>
{segments.filter((s) => s.lane < MAX_LANES).map(renderBar)}
</div>
)}
<div className="kx-cv-week-grid kx-cv-week-body">
{cols.map((date, dow) => {
const singles = singleByDate.get(date) ?? [];
const hiddenHere = hiddenBars.filter((s) => s.startCol === dow).length;
return (
<div
key={date}
className={`kx-cv-week-cell${date === today ? ' is-today' : ''}${date === selectedDate ? ' is-selected' : ''}`}
onClick={() => onDateClick?.(date)}
>
{singles.map(renderItem)}
{hiddenHere > 0 && (
<button
type="button"
className="kx-cv-more"
onClick={(e) => {
e.stopPropagation();
onMore(date);
}}
>
+{hiddenHere}
</button>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,541 @@
/* UIWS calendarView.css 정본 이식 클래스 kx-cv-*, kintex 디자인 토큰만 사용.
// 전환·기간 막대(spanning bar)·+N 더보기 팝업. */
.kx-cv-root {
width: 100%;
min-width: 0;
}
/* ---- 툴바 ---- */
.kx-cv-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.kx-cv-nav {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-700);
font-size: var(--fs-caption);
cursor: pointer;
}
.kx-cv-nav:hover {
background: var(--color-neutral-050);
}
.kx-cv-title {
font-size: var(--fs-h3);
font-weight: 700;
color: var(--color-neutral-900);
min-width: 160px;
text-align: center;
}
.kx-cv-switch {
display: inline-flex;
border: var(--border-card);
border-radius: var(--radius-sm);
overflow: hidden;
}
.kx-cv-switch button {
border: none;
background: var(--color-white);
color: var(--color-neutral-700);
padding: 4px 12px;
cursor: pointer;
font-size: var(--fs-caption);
}
.kx-cv-switch button.is-active {
background: var(--color-primary-600);
color: #fff;
}
.kx-cv-muted {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
/* ---- 범례 ---- */
.kx-cv-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-3);
margin-bottom: var(--space-2);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-cv-legend-item {
display: inline-flex;
align-items: center;
}
/* ---- 월 그리드 ---- */
.kx-cv-grid {
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-white);
min-width: 0;
}
/* 피드백(데스크톱 2단 한정): 달력이 컬럼의 남는 세로 공간을 채워 아래 대형 공간 제거.
태블릿/모바일(1100 단일 컬럼)에서는 콘텐츠 높이 유지(밀도 정리 우선). */
@media (min-width: 1101px) {
.kx-cv-root {
display: flex;
flex-direction: column;
min-height: 0;
}
.kx-cv-root .kx-cv-grid {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.kx-cv-root .kx-cv-grid .kx-cv-week-row {
flex: 1 1 auto;
}
.kx-cv-root .kx-cv-grid .kx-cv-week-cells {
height: 100%;
}
}
.kx-cv-dow-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.kx-cv-dow {
padding: var(--space-2);
text-align: center;
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
background: var(--color-neutral-050);
border-bottom: var(--border-card);
}
.kx-cv-dow.is-sun {
color: var(--color-error);
}
.kx-cv-dow.is-sat {
color: var(--color-primary-600);
}
/* 한 주 행: 날짜 셀 레이어 위에 기간 막대 레이어를 겹친다. */
.kx-cv-week-row {
position: relative;
--kx-cv-bar-lanes: 0;
--kx-cv-bar-h: 18px;
--kx-cv-bar-gap: 2px;
--kx-cv-daynum-h: 16px;
}
.kx-cv-week-cells {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.kx-cv-cell {
position: relative;
min-width: 0;
/* 피드백②: 이벤트 없는 날 아래 대형 공백 → 셀 높이 축소(콘텐츠 밀도 정리). */
min-height: 64px;
border-top: var(--border-card);
border-right: var(--border-card);
padding: var(--space-1) var(--space-2) var(--space-2);
font-size: var(--fs-caption);
background: var(--color-white);
cursor: pointer;
/* 날짜 숫자를 셀 최상단에 붙이고(위로), 막대 레인 수만큼만 상단 여백 확보. */
padding-top: calc(
var(--space-1) + var(--kx-cv-daynum-h) +
var(--kx-cv-bar-lanes) * (var(--kx-cv-bar-h) + var(--kx-cv-bar-gap))
);
}
.kx-cv-cell:nth-child(7n) {
border-right: none;
}
.kx-cv-cell.is-out {
background: var(--color-neutral-050);
color: var(--color-neutral-500);
}
.kx-cv-cell.is-today {
outline: 2px solid var(--color-primary-600);
outline-offset: -2px;
}
.kx-cv-cell.is-selected {
background: var(--color-primary-050);
}
.kx-cv-daynum {
position: absolute;
top: var(--space-1);
left: var(--space-2);
height: var(--kx-cv-daynum-h);
line-height: var(--kx-cv-daynum-h);
font-weight: 600;
color: var(--color-neutral-700);
z-index: 2;
}
.kx-cv-cell.is-sun .kx-cv-daynum {
color: var(--color-error);
}
.kx-cv-cell.is-sat .kx-cv-daynum {
color: var(--color-primary-600);
}
/* 공휴일 — 일요일과 동일 톤(붉은색). 날짜 숫자 + 옆 라벨(공휴일명) 모두 강조. */
.kx-cv-cell.is-holiday .kx-cv-daynum {
color: var(--color-error);
}
/* 날짜 숫자 옆 공휴일명 라벨 — 셀 최상단 한 줄(밀도 유지·초과 시 말줄임). */
.kx-cv-holiday-name {
display: inline-block;
margin-left: 4px;
font-style: normal;
font-size: 11px;
font-weight: 600;
color: var(--color-error);
max-width: 72px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
/* 주 뷰 헤더·일 뷰 헤더 공휴일 강조. */
.kx-cv-dow.is-holiday,
.kx-cv-day.is-holiday .kx-cv-day-head {
color: var(--color-error);
}
.kx-cv-cell-singles {
display: block;
min-width: 0;
}
/* 기간 막대 레이어 */
.kx-cv-bars {
position: absolute;
top: calc(var(--space-1) + var(--kx-cv-daynum-h));
left: 0;
right: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: var(--kx-cv-bar-h);
row-gap: var(--kx-cv-bar-gap);
pointer-events: none;
padding: 0 2px;
}
/* ---- 기간 막대(bar) ---- */
.kx-cv-bar {
pointer-events: auto;
min-width: 0;
display: flex;
align-items: center;
height: var(--kx-cv-bar-h);
margin: 0 1px;
padding: 0 6px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-primary-050);
color: var(--color-primary-700);
border-left: 3px solid var(--color-primary-600);
font-size: 11px;
line-height: var(--kx-cv-bar-h);
overflow: hidden;
white-space: nowrap;
cursor: pointer;
text-align: left;
}
.kx-cv-bar:hover {
background: var(--color-primary-600);
color: #fff;
}
.kx-cv-bar.is-cont-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
margin-left: 0;
}
.kx-cv-bar.is-cont-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
margin-right: 0;
}
.kx-cv-bar.is-done {
color: var(--color-neutral-500);
}
.kx-cv-bar-badge {
flex: 0 0 auto;
margin-right: 4px;
padding: 0 4px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.08);
font-weight: 600;
}
.kx-cv-bar-title {
overflow: hidden;
text-overflow: ellipsis;
}
/* "+N" 더보기 */
.kx-cv-more {
display: inline-block;
margin-top: 2px;
padding: 0 4px;
border: none;
background: transparent;
font-size: 10px;
font-weight: 600;
color: var(--color-neutral-500);
cursor: pointer;
border-radius: var(--radius-sm);
}
.kx-cv-more:hover {
background: var(--color-primary-050);
color: var(--color-primary-600);
}
/* ---- 주 뷰 ---- */
.kx-cv-week-view {
border: var(--border-card);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-white);
}
.kx-cv-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--color-white);
}
.kx-cv-week-grid .kx-cv-dow {
border-right: var(--border-card);
}
.kx-cv-week-grid .kx-cv-dow:nth-child(7n) {
border-right: none;
}
.kx-cv-week-bars {
--kx-cv-bar-lanes: 0;
--kx-cv-bar-h: 18px;
--kx-cv-bar-gap: 2px;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: var(--kx-cv-bar-h);
row-gap: var(--kx-cv-bar-gap);
padding: var(--space-1) 2px;
border-bottom: var(--border-card);
background: var(--color-neutral-050);
}
.kx-cv-week-cell {
min-width: 0;
min-height: 200px;
border-top: var(--border-card);
border-right: var(--border-card);
padding: var(--space-2);
cursor: pointer;
}
.kx-cv-week-body .kx-cv-week-cell:nth-child(7n) {
border-right: none;
}
.kx-cv-week-cell.is-today {
outline: 2px solid var(--color-primary-600);
outline-offset: -2px;
}
.kx-cv-week-cell.is-selected {
background: var(--color-primary-050);
}
/* ---- 일 뷰 ---- */
.kx-cv-day {
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
padding: var(--space-3);
min-height: 200px;
cursor: pointer;
}
.kx-cv-day-head {
margin-bottom: var(--space-2);
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-cv-day-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* ---- 단일일 항목(칩)·뱃지 ---- */
.kx-cv-event {
display: flex;
align-items: center;
max-width: 100%;
margin-top: var(--space-1);
padding: 2px 6px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-primary-050);
color: var(--color-primary-700);
cursor: pointer;
font-size: 11px;
line-height: 1.4;
overflow: hidden;
white-space: nowrap;
border-left: 3px solid var(--color-primary-600);
text-align: left;
width: 100%;
}
.kx-cv-event:hover {
background: var(--color-primary-600);
color: #fff;
}
.kx-cv-event.is-done .kx-cv-event-title {
color: var(--color-neutral-500);
}
.kx-cv-event-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* 진행상태 색점 */
.kx-cv-dot {
flex: 0 0 auto;
display: inline-block;
width: 7px;
height: 7px;
margin-right: 5px;
border-radius: 50%;
background: var(--color-neutral-500);
vertical-align: middle;
}
.kx-cv-dot.kx-cv-imp-UPCOMING {
background: var(--color-warning);
}
.kx-cv-dot.kx-cv-imp-ONGOING {
background: var(--color-primary-600);
}
.kx-cv-dot.kx-cv-imp-DONE {
background: var(--color-success);
}
.kx-cv-badge {
display: inline-block;
flex: 0 0 auto;
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
margin-right: 4px;
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
/* ---- "+N" 더보기 팝업 ---- */
.kx-cv-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(16, 24, 40, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-4);
}
.kx-cv-modal {
width: 100%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-level2);
padding: var(--space-4);
}
.kx-cv-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
color: var(--color-neutral-900);
}
.kx-cv-modal-close {
border: none;
background: transparent;
color: var(--color-neutral-500);
cursor: pointer;
display: inline-flex;
padding: 4px;
border-radius: var(--radius-sm);
}
.kx-cv-modal-close:hover {
background: var(--color-neutral-050);
}
.kx-cv-more-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.kx-cv-more-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 10px;
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-900);
cursor: pointer;
text-align: left;
font-size: var(--fs-caption);
}
.kx-cv-more-item:hover {
background: var(--color-neutral-050);
border-color: var(--color-primary-600);
}
.kx-cv-more-item-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.kx-cv-more-item-range {
flex: 0 0 auto;
font-size: 11px;
color: var(--color-neutral-500);
}
@media (max-width: 640px) {
.kx-cv-cell {
min-height: 52px;
padding: 2px;
padding-top: calc(
var(--space-1) + var(--kx-cv-daynum-h) +
var(--kx-cv-bar-lanes) * (var(--kx-cv-bar-h) + var(--kx-cv-bar-gap))
);
}
.kx-cv-week-cell {
min-height: 120px;
}
.kx-cv-bar-title {
font-size: 10px;
}
/* 좁은 화면: 툴바 제목 고정폭 해제(가로 오버플로 방지). */
.kx-cv-title {
min-width: 0;
flex: 1 1 auto;
text-align: left;
}
.kx-cv-dow {
padding: var(--space-1);
font-size: 11px;
}
}

View File

@ -33,9 +33,16 @@
.kx-sched__split { .kx-sched__split {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; /* 소유자 피드백①: 어중간한 2:1 → 균형 50:50. 달력 7열이 반폭에서 답답하지 않게 좌측 최소폭 확보. */
grid-template-columns: minmax(0, 1fr) 1fr;
gap: var(--space-4); gap: var(--space-4);
align-items: start; /* 피드백③: 달력이 컬럼 높이를 채우도록 stretch(달력 아래 대형 빈 공간 제거). aside는 자연 높이. */
align-items: stretch;
flex: 1 1 auto;
min-height: 0;
}
.kx-sched__aside {
align-self: start;
} }
.kx-sched__list { .kx-sched__list {
display: flex; display: flex;
@ -322,7 +329,8 @@
@media (max-width: 1100px) { @media (max-width: 1100px) {
.kx-sched__split { .kx-sched__split {
grid-template-columns: 1fr; /* 단일 컬럼도 min-width:0 확보(7열 그리드가 뷰포트를 넘지 않게). */
grid-template-columns: minmax(0, 1fr);
} }
} }
@media (max-width: 620px) { @media (max-width: 620px) {

View File

@ -139,19 +139,55 @@
white-space: nowrap; white-space: nowrap;
} }
/* 테이블 확장 */ /*
* 데이터 그리드 표준 Nifty "Advanced table headers" 계보
* (GUARDiA ITSM Nifty 다크 테마 계보와 정합)
* 헤더: 12px / 600 / 중간색(neutral-500) / 좌정렬 / 서브톤 배경 / 2px 하단 구분선
* 본문: 14px / neutral-700 / 1px 구분선(neutral-200) / 미들 정렬
* 숫자열(.kx-num): 우정렬 tabular-nums
* 화면 테이블(.kx-list-table·.kx-aq__table·.kx-adm-*·.kx-i18n-table·
* .kxp-ticktable·.kx-boq__table ) 정의로 폰트·정렬·헤더 스타일을 수렴한다.
* */
.kx-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-table thead th {
position: sticky;
top: 0;
z-index: 1;
padding: 10px 12px;
font-size: var(--fs-caption);
font-weight: 600;
color: var(--color-neutral-500);
text-align: left;
letter-spacing: 0.02em;
background: var(--color-neutral-050);
border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap;
vertical-align: bottom;
}
.kx-table tbody td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-neutral-200);
color: var(--color-neutral-700);
vertical-align: middle;
}
.kx-table tbody tr:hover {
background: var(--row-hover);
}
.kx-table-scroll { .kx-table-scroll {
overflow-x: auto; overflow-x: auto;
} }
.kx-table--zebra tbody tr:nth-child(even) { .kx-table--zebra tbody tr:nth-child(even) {
background: var(--zebra); background: var(--zebra);
} }
.kx-table thead th { /* 숫자열: 우정렬 + 등폭 숫자(정렬 통일) */
position: sticky;
top: 0;
}
.kx-num { .kx-num {
text-align: right; text-align: right;
font-variant-numeric: tabular-nums;
} }
/* 가로 막대 리스트 */ /* 가로 막대 리스트 */

View File

@ -4,6 +4,7 @@
* : AI ·(//) AI UI ( ). 07_work_api_gaps.md . * : AI ·(//) AI UI ( ). 07_work_api_gaps.md .
*/ */
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { searchApi } from '../../api/endpoints'; import { searchApi } from '../../api/endpoints';
import type { SearchResultItem } from '../../api/types'; import type { SearchResultItem } from '../../api/types';
@ -25,9 +26,46 @@ const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> =
MEETING: { label: '회의록', Icon: IconCalendar }, MEETING: { label: '회의록', Icon: IconCalendar },
REPORT: { label: '업무보고', Icon: IconDocument }, REPORT: { label: '업무보고', Icon: IconDocument },
OPINION: { label: '의견', Icon: IconDocument }, OPINION: { label: '의견', Icon: IconDocument },
EVENT: { label: '행사', Icon: IconCalendar },
COMPANY: { label: '업체', Icon: IconDocument },
AUCTION: { label: '옥션', Icon: IconDocument },
BOOTH: { label: '부스', Icon: IconDocument },
LEAD: { label: '리드', Icon: IconDocument },
CMS: { label: '콘텐츠', Icon: IconDocument },
}; };
/** type(+id)별 이동 경로 파생(백엔드 route 와 정합). null=이동 불가. */
function routeFor(type: string, id: string): string | null {
switch (type) {
case 'WORKLOG':
return '/work/worklog';
case 'NOTICE':
return '/work/notice';
case 'MEETING':
return '/work/meeting';
case 'REPORT':
return '/work/report';
case 'OPINION':
return '/work/opinion';
case 'EVENT':
return '/schedule';
case 'COMPANY':
return '/admin/companies';
case 'AUCTION':
return id ? `/auctions/${id}` : '/auctions';
case 'BOOTH':
return '/booth-sales';
case 'LEAD':
return '/leads';
case 'CMS':
return '/cms';
default:
return null;
}
}
export function SearchPage() { export function SearchPage() {
const navigate = useNavigate();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [aiMode, setAiMode] = useState(false); const [aiMode, setAiMode] = useState(false);
@ -73,7 +111,7 @@ export function SearchPage() {
<header className="kx-work__head"> <header className="kx-work__head">
<div> <div>
<h1 className="kx-work__title"></h1> <h1 className="kx-work__title"></h1>
<p className="kx-work__subtitle">···· </p> <p className="kx-work__subtitle">···· + ····· </p>
</div> </div>
</header> </header>
@ -156,8 +194,18 @@ export function SearchPage() {
{filtered.map((r: SearchResultItem) => { {filtered.map((r: SearchResultItem) => {
const meta = TYPE_META[r.type] ?? { label: r.type, Icon: IconDocument }; const meta = TYPE_META[r.type] ?? { label: r.type, Icon: IconDocument };
const Icon = meta.Icon; const Icon = meta.Icon;
const route = routeFor(r.type, r.id);
const go = () => route && navigate(route);
return ( return (
<div className="kx-search__result" key={`${r.type}-${r.id}`}> <div
className="kx-search__result"
key={`${r.type}-${r.id}`}
onClick={route ? go : undefined}
onKeyDown={route ? (e) => (e.key === 'Enter' || e.key === ' ') && go() : undefined}
role={route ? 'button' : undefined}
tabIndex={route ? 0 : undefined}
style={route ? { cursor: 'pointer' } : undefined}
>
<span className="kx-search__result-icon"> <span className="kx-search__result-icon">
<Icon size={18} /> <Icon size={18} />
</span> </span>

View File

@ -8,12 +8,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { scheduleWorkApi } from '../../api/endpoints'; import { scheduleWorkApi } from '../../api/endpoints';
import type { ScheduleDto, ScheduleSaveRequest } from '../../api/types'; import type { ScheduleDto, ScheduleSaveRequest } from '../../api/types';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState } from '../../components/ui/States';
import { IconChevronLeft, IconChevronRight, IconPlus } from '../../components/ui/icons'; import { IconPlus } from '../../components/ui/icons';
import KxCalendarView, { type KxCalendarItem } from '../schedule/KxCalendarView';
import { fetchHolidaySet } from '../admin/holidayApi';
import { errMessage, fmtDateTime, useToast } from './workShared'; import { errMessage, fmtDateTime, useToast } from './workShared';
import './work.css'; import './work.css';
const WD = ['일', '월', '화', '수', '목', '금', '토'];
const TYPE_COLOR: Record<string, string> = { const TYPE_COLOR: Record<string, string> = {
milestone: '#0066B3', milestone: '#0066B3',
auction: '#6D4AFF', auction: '#6D4AFF',
@ -36,11 +37,34 @@ function colorOf(s: ScheduleDto): string {
return s.color || TYPE_COLOR[(s.scheduleType ?? '').toLowerCase()] || '#0066B3'; return s.color || TYPE_COLOR[(s.scheduleType ?? '').toLowerCase()] || '#0066B3';
} }
/** 표시 월(baseDate 기준 일요일 시작 6주 그리드)의 조회 범위(yyyy-MM-dd). */
function monthRange(d: Date): { fromDate: string; toDate: string } {
const first = new Date(d.getFullYear(), d.getMonth(), 1);
const start = new Date(first);
start.setDate(1 - first.getDay());
const end = new Date(start);
end.setDate(start.getDate() + 41);
return { fromDate: ymd(start), toDate: ymd(end) };
}
/** ScheduleDto → KxCalendarItem(기간=시작~종료일, 유형색·라벨). startAt 없으면 제외(호출부 필터). */
function toWorkItem(s: ScheduleDto): KxCalendarItem {
return {
id: String(s.id),
startDate: (s.startAt ?? '').slice(0, 10),
endDate: (s.endAt ?? s.startAt ?? '').slice(0, 10),
title: s.title,
color: colorOf(s),
badge: TYPE_LABEL[(s.scheduleType ?? '').toLowerCase()],
};
}
export function WorkSchedulePage() { export function WorkSchedulePage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { show, node: toast } = useToast(); const { show, node: toast } = useToast();
const today = new Date(); const today = new Date();
const [cursor, setCursor] = useState({ y: today.getFullYear(), m: today.getMonth() }); // 표시 범위(캘린더 뷰/기준일 변경 시 KxCalendarView.onRangeChange 로 갱신). 초기=이번 달 그리드.
const [range, setRange] = useState(() => monthRange(today));
const [selDate, setSelDate] = useState(ymd(today)); const [selDate, setSelDate] = useState(ymd(today));
const [composing, setComposing] = useState(false); const [composing, setComposing] = useState(false);
const [form, setForm] = useState<ScheduleSaveRequest>({ const [form, setForm] = useState<ScheduleSaveRequest>({
@ -53,29 +77,23 @@ export function WorkSchedulePage() {
content: '', content: '',
}); });
// 표시 그리드(월 시작 주 일요일 ~ 6주) 범위로 조회.
const grid = useMemo(() => {
const first = new Date(cursor.y, cursor.m, 1);
const start = new Date(first);
start.setDate(1 - first.getDay());
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push(d);
}
return { start: days[0], end: days[41], days };
}, [cursor]);
const listQ = useQuery({ const listQ = useQuery({
queryKey: ['schedules', ymd(grid.start), ymd(grid.end)], queryKey: ['schedules', range.fromDate, range.toDate],
queryFn: () => queryFn: () =>
scheduleWorkApi.list({ scheduleWorkApi.list({
fromAt: `${ymd(grid.start)}T00:00:00`, fromAt: `${range.fromDate}T00:00:00`,
toAt: `${ymd(grid.end)}T23:59:59`, toAt: `${range.toDate}T23:59:59`,
}), }),
}); });
// 공휴일 세트(캘린더 붉은색 표시) — 표시 범위별 조회. 실패해도 캘린더는 정상 동작(빈 세트).
const holidayQ = useQuery({
queryKey: ['holidays', range.fromDate, range.toDate],
queryFn: () => fetchHolidaySet(range.fromDate, range.toDate),
retry: false,
});
const holidays = holidayQ.data ?? new Map<string, string>();
const byDay = useMemo(() => { const byDay = useMemo(() => {
const map = new Map<string, ScheduleDto[]>(); const map = new Map<string, ScheduleDto[]>();
for (const s of listQ.data ?? []) { for (const s of listQ.data ?? []) {
@ -88,6 +106,16 @@ export function WorkSchedulePage() {
return map; return map;
}, [listQ.data]); }, [listQ.data]);
// 월/주/일 뷰 항목(기간 막대·유형색). startAt 있는 항목만.
const calItems = useMemo(
() => (listQ.data ?? []).filter((s) => !!(s.startAt ?? '').slice(0, 10)).map(toWorkItem),
[listQ.data],
);
const legend = useMemo(
() => Object.entries(TYPE_LABEL).map(([code, label]) => ({ code, label, color: TYPE_COLOR[code] })),
[],
);
const createM = useMutation({ const createM = useMutation({
mutationFn: (body: ScheduleSaveRequest) => scheduleWorkApi.create(body), mutationFn: (body: ScheduleSaveRequest) => scheduleWorkApi.create(body),
onSuccess: () => { onSuccess: () => {
@ -107,7 +135,6 @@ export function WorkSchedulePage() {
}); });
const agenda = byDay.get(selDate) ?? []; const agenda = byDay.get(selDate) ?? [];
const todayKey = ymd(today);
function openCompose() { function openCompose() {
setForm({ setForm({
@ -127,89 +154,22 @@ export function WorkSchedulePage() {
<header className="kx-work__head"> <header className="kx-work__head">
<div> <div>
<h1 className="kx-work__title"></h1> <h1 className="kx-work__title"></h1>
<p className="kx-work__subtitle">· · // </p> <p className="kx-work__subtitle">· · // · //</p>
</div>
<div className="kx-work__head-actions">
<Button
variant="ghost"
onClick={() =>
setCursor((c) => (c.m === 0 ? { y: c.y - 1, m: 11 } : { y: c.y, m: c.m - 1 }))
}
aria-label="이전 달"
leadingIcon={<IconChevronLeft size={16} />}
>
</Button>
<strong style={{ minWidth: 120, textAlign: 'center' }}>
{cursor.y} {cursor.m + 1}
</strong>
<Button
variant="ghost"
onClick={() =>
setCursor((c) => (c.m === 11 ? { y: c.y + 1, m: 0 } : { y: c.y, m: c.m + 1 }))
}
aria-label="다음 달"
>
<IconChevronRight size={16} />
</Button>
</div> </div>
</header> </header>
<div className="kx-cal"> <div className="kx-cal">
<div> <div>
<div className="kx-cal__grid" role="grid" aria-label="월 캘린더"> <KxCalendarView
<div className="kx-cal__weekhead" role="row"> items={calItems}
{WD.map((w, i) => ( legend={legend}
<div key={w} className={`kx-cal__wd ${i === 0 ? 'kx-cal__wd--sun' : ''}`} role="columnheader"> selectedDate={selDate}
{w} loading={listQ.isLoading}
</div> holidays={holidays}
))} onRangeChange={(r) => setRange(r)}
</div> onDateClick={(d) => setSelDate(d)}
{Array.from({ length: 6 }).map((_, wk) => ( onItemClick={(it) => setSelDate(it.startDate)}
<div className="kx-cal__week" role="row" key={wk}> />
{grid.days.slice(wk * 7, wk * 7 + 7).map((d) => {
const key = ymd(d);
const evts = byDay.get(key) ?? [];
const isOther = d.getMonth() !== cursor.m;
return (
<div
key={key}
role="gridcell"
className={`kx-cal__cell ${isOther ? 'is-other' : ''} ${
key === selDate ? 'is-selected' : ''
} ${key === todayKey ? 'is-today' : ''}`}
onClick={() => setSelDate(key)}
>
<span className="kx-cal__daynum">{d.getDate()}</span>
{listQ.isLoading && isOther === false && wk === 1 && (
<Skeleton height={14} />
)}
{evts.slice(0, 3).map((s) => (
<span
key={s.id}
className="kx-cal__event"
style={{ background: colorOf(s) }}
title={s.title}
>
{s.title}
</span>
))}
{evts.length > 3 && <span className="kx-cal__more">+{evts.length - 3}</span>}
</div>
);
})}
</div>
))}
</div>
<div className="kx-cal__legend" style={{ marginTop: 12 }}>
{Object.entries(TYPE_LABEL).map(([k, label]) => (
<span key={k}>
<span className="kx-cal__swatch" style={{ background: TYPE_COLOR[k] }} />
{label}
</span>
))}
</div>
{listQ.isError && ( {listQ.isError && (
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} /> <ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
)} )}

View File

@ -29,10 +29,10 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* ── 목록/상세 2-Pane 분할 (worklog·opinion) ── */ /* ── 목록/상세 2-Pane 분할 (worklog·opinion) — 좌우 50:50(소유자 지시 2026-07-12) ── */
.kx-split { .kx-split {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-4); gap: var(--space-4);
align-items: start; align-items: start;
} }
@ -102,18 +102,18 @@
z-index: 1; z-index: 1;
background: var(--color-neutral-050); background: var(--color-neutral-050);
text-align: left; text-align: left;
padding: 0 var(--space-3); padding: 10px 12px;
height: 40px;
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-neutral-500); color: var(--color-neutral-500);
border-bottom: var(--border-card); border-bottom: 2px solid var(--color-neutral-200);
white-space: nowrap; white-space: nowrap;
vertical-align: bottom;
} }
.kx-list-table tbody td { .kx-list-table tbody td {
padding: 0 var(--space-3); padding: 10px 12px;
height: 44px; border-bottom: 1px solid var(--color-neutral-200);
border-bottom: 1px solid var(--color-neutral-100);
color: var(--color-neutral-700); color: var(--color-neutral-700);
vertical-align: middle; vertical-align: middle;
} }
@ -220,6 +220,7 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--space-3); gap: var(--space-3);
max-width: 640px; /* 입력 폼이 콘텐츠 전폭으로 늘어나지 않게 제한(WISE 폼 폭) */
} }
.kx-formgrid .kx-field--full { .kx-formgrid .kx-field--full {
grid-column: 1 / -1; grid-column: 1 / -1;

View File

@ -82,6 +82,9 @@ interface MdiState {
* @returns ( null) navigate . * @returns ( null) navigate .
*/ */
closeTab: (path: string) => string | null; closeTab: (path: string) => string | null;
/** 전 탭 닫기(모두 닫기). @returns 이동할 기본 경로('/home') — 호출부가 navigate 담당. */
closeAll: () => string;
} }
const initial = readStored(); const initial = readStored();
@ -137,4 +140,11 @@ export const useMdiStore = create<MdiState>((set, get) => ({
set(state); set(state);
return nextActive; return nextActive;
}, },
closeAll: () => {
const state = { tabs: [], activePath: null };
persist(state);
set(state);
return '/home';
},
})); }));