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:
parent
3649870082
commit
433eb83a16
96
docs/DESIGN_SYSTEM_NIFTY.md
Normal file
96
docs/DESIGN_SYSTEM_NIFTY.md
Normal 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 스타일 학습" 지시 |
|
||||
108
docs/PLANNING.md
108
docs/PLANNING.md
@ -1,6 +1,6 @@
|
||||
# 킨텍스 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-트랙 기획·타 문서 후속 반영 시 이 명칭을 사용한다(기존 "자동전시시스템" 표기는 문맥 보존을 위해 삭제하지 않고 잔존하나, 신규 표기는 정본 명칭을 따른다).
|
||||
> 근거 문서: `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-트랙 재구성 확정 근거
|
||||
@ -10,6 +10,7 @@
|
||||
> **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.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 후속 반영 필요"로만 표기하고 직접 수정하지 않는다.
|
||||
|
||||
---
|
||||
@ -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-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)·우선순위·나노바나나 파이프라인 로직 불변** — 스택 표기 정합화만 수행 |
|
||||
| **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.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.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 후속 반영 필요), 시크릿 미기재 |
|
||||
|
||||
30
docs/analysis/nifty-design-refs.md
Normal file
30
docs/analysis/nifty-design-refs.md
Normal 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 정합 확인 후 파일럿→확산.
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.zioinfo.kintex.system.holiday;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** 공휴일 휴무 여부 토글 요청 — 임시휴무 on/off(관리자). */
|
||||
public record HolidayToggleRequest(
|
||||
@NotNull Boolean isHoliday) {
|
||||
}
|
||||
@ -8,8 +8,9 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 통합검색 매퍼 — 공통 업무 테이블을 title 기준으로 검색(UNION). DataScope:
|
||||
* seeAll 이 false 면 worklog/report/opinion 은 본인 것 + 비밀 아님만.
|
||||
* 통합검색 매퍼 — 공통 업무 테이블 + 도메인(행사·업체·옥션·부스·리드·CMS)을 UNION 검색.
|
||||
* DataScope: seeAll 이 false 면 worklog/report/opinion 은 본인 것 + 비밀 아님만.
|
||||
* 도메인: event 는 tenant_id='KINTEX' 스코프, lead 는 PII 원문(name/phone/email) 미노출(company·product 만).
|
||||
*/
|
||||
@Mapper
|
||||
public interface SearchMapper {
|
||||
@ -39,6 +40,31 @@ public interface SearchMapper {
|
||||
FROM opinion
|
||||
WHERE title ILIKE '%'||#{q}||'%'
|
||||
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}
|
||||
</script>
|
||||
""")
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
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) {
|
||||
}
|
||||
|
||||
@ -26,8 +26,32 @@ public class SearchService {
|
||||
int size = limit <= 0 ? 30 : Math.min(limit, 100);
|
||||
boolean seeAll = scope.canSeeAll(principal);
|
||||
List<Map<String, Object>> rows = mapper.searchAll(q.trim(), principal.userId(), seeAll, size);
|
||||
return rows.stream().map(r -> new SearchResultItem(
|
||||
str(r.get("type")), str(r.get("id")), str(r.get("title")), str(r.get("createdAt")))).toList();
|
||||
return rows.stream().map(r -> {
|
||||
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); }
|
||||
|
||||
@ -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 존재
|
||||
-- ============================================================================
|
||||
@ -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;
|
||||
@ -24,6 +24,7 @@ import { LoginHistoryPage } from './screens/admin/LoginHistoryPage';
|
||||
import { ErrorLogPage } from './screens/admin/ErrorLogPage';
|
||||
import { SystemHealthPage } from './screens/admin/SystemHealthPage';
|
||||
import { MailConfigPage, NotifyConfigPage } from './screens/admin/InfraConfigPage';
|
||||
import { HolidayAdminPage } from './screens/admin/HolidayAdminPage';
|
||||
import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage';
|
||||
import { AnalyticsDashboardPage } from './screens/analytics/AnalyticsDashboardPage';
|
||||
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/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} />
|
||||
<Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} />
|
||||
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
|
||||
</Route>
|
||||
|
||||
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
|
||||
|
||||
@ -180,6 +180,7 @@ const GROUPS: NavGroupDef[] = [
|
||||
{ key: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true },
|
||||
{ key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' },
|
||||
{ key: 'admin-rulesets', label: '룰셋', labelKey: 'shell.menu.items.rulesets', Icon: IconOperations, to: '/admin/masterdata/rulesets' },
|
||||
{ key: 'admin-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' },
|
||||
],
|
||||
},
|
||||
@ -447,10 +448,20 @@ export function AppShell() {
|
||||
className="kx-shell__icon-btn"
|
||||
type="button"
|
||||
aria-label="알림"
|
||||
title="알림"
|
||||
onClick={() => navigate('/notifications')}
|
||||
>
|
||||
<IconBell size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="kx-shell__icon-btn"
|
||||
type="button"
|
||||
aria-label="설정"
|
||||
title="설정"
|
||||
onClick={() => navigate(isAdmin ? '/admin/settings' : '/me')}
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ export function MdiTabBar() {
|
||||
const tabs = useMdiStore((s) => s.tabs);
|
||||
const activePath = useMdiStore((s) => s.activePath);
|
||||
const closeTab = useMdiStore((s) => s.closeTab);
|
||||
const closeAll = useMdiStore((s) => s.closeAll);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
@ -79,6 +80,21 @@ export function MdiTabBar() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -570,3 +570,26 @@
|
||||
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);
|
||||
}
|
||||
|
||||
154
src/frontend/src/components/ui/Offcanvas.tsx
Normal file
154
src/frontend/src/components/ui/Offcanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/frontend/src/components/ui/offcanvas.css
Normal file
122
src/frontend/src/components/ui/offcanvas.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1080,7 +1080,20 @@
|
||||
"rentalSubtotal": "Rental subtotal",
|
||||
"rentalTotal": "Rental total ({{days}} days)",
|
||||
"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": {
|
||||
"console": {
|
||||
@ -1701,7 +1714,68 @@
|
||||
"demoFootNote": "Select a document from the approval inbox to approve or reject.",
|
||||
"demoHint": "No approval document selected. Please open one from the approval inbox.",
|
||||
"commentPlaceholder": "Enter a comment (required to reject)",
|
||||
"comment": "Comment"
|
||||
"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": {
|
||||
"boq": {
|
||||
@ -1887,7 +1961,21 @@
|
||||
"errRequired": "Please enter a department name.",
|
||||
"loadError": "Failed to load the department list.",
|
||||
"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": {
|
||||
"title": "Error Log",
|
||||
@ -2000,5 +2088,57 @@
|
||||
"home": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1080,7 +1080,20 @@
|
||||
"rentalSubtotal": "賃料小計",
|
||||
"rentalTotal": "賃料合計 ({{days}}日)",
|
||||
"deposit": "管理費予託金(別途)",
|
||||
"ruleset": "(ルールセット {{v}})"
|
||||
"ruleset": "(ルールセット {{v}})",
|
||||
"openDetail": "{{event}} の割当詳細を開く",
|
||||
"detailTitle": "割当詳細",
|
||||
"edit": "編集",
|
||||
"dHall": "ホール",
|
||||
"dPeriod": "占有期間",
|
||||
"dStatus": "ステータス",
|
||||
"dNote": "備考",
|
||||
"primaryTag": "主ホール",
|
||||
"dConflict": "競合状況",
|
||||
"dNoConflict": "重複する割当はありません。",
|
||||
"dQuote": "自動見積サマリー",
|
||||
"dQuoteNoPeriod": "占有期間がないため見積を計算できません。",
|
||||
"dQuoteFail": "見積を取得できませんでした。"
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
@ -1701,7 +1714,68 @@
|
||||
"demoFootNote": "承認受信箱から文書を選択すると承認/却下できます。",
|
||||
"demoHint": "承認文書が指定されていません。承認受信箱から開いてください。",
|
||||
"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": {
|
||||
"boq": {
|
||||
@ -1887,7 +1961,21 @@
|
||||
"errRequired": "部門名を入力してください。",
|
||||
"loadError": "部門一覧の読み込みに失敗しました。",
|
||||
"emptyTitle": "登録された部門がありません",
|
||||
"emptyDesc": "最上位部門を追加してください。"
|
||||
"emptyDesc": "最上位部門を追加してください。",
|
||||
"searchPlaceholder": "部署名で検索",
|
||||
"searchNoResult": "一致する部署がありません。",
|
||||
"treeAria": "部署組織ツリー",
|
||||
"expand": "展開",
|
||||
"collapse": "折りたたむ",
|
||||
"expandAll": "すべて展開",
|
||||
"collapseAll": "すべて折りたたむ",
|
||||
"selectHint": "左のツリーから部署を選択してください。",
|
||||
"colId": "部署ID",
|
||||
"colParent": "上位部署",
|
||||
"topLevel": "最上位",
|
||||
"colChildCount": "下位部署数",
|
||||
"childCountValue": "{{n}}件",
|
||||
"errHasChildren": "下位部署があるため削除できません。"
|
||||
},
|
||||
"errorLog": {
|
||||
"title": "エラーログ",
|
||||
@ -2000,5 +2088,57 @@
|
||||
"home": {
|
||||
"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": "保存"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1080,7 +1080,20 @@
|
||||
"rentalSubtotal": "임대료 소계",
|
||||
"rentalTotal": "임대료 합계 ({{days}}일)",
|
||||
"deposit": "관리비 예치금(별도)",
|
||||
"ruleset": "(룰셋 {{v}})"
|
||||
"ruleset": "(룰셋 {{v}})",
|
||||
"openDetail": "{{event}} 배정 상세 열기",
|
||||
"detailTitle": "배정 상세",
|
||||
"edit": "수정",
|
||||
"dHall": "홀",
|
||||
"dPeriod": "점유 기간",
|
||||
"dStatus": "상태",
|
||||
"dNote": "비고",
|
||||
"primaryTag": "주 배정 홀",
|
||||
"dConflict": "충돌 여부",
|
||||
"dNoConflict": "겹치는 배정이 없습니다.",
|
||||
"dQuote": "자동 견적 요약",
|
||||
"dQuoteNoPeriod": "점유 기간이 없어 견적을 계산할 수 없습니다.",
|
||||
"dQuoteFail": "견적을 불러오지 못했습니다."
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
@ -1701,7 +1714,68 @@
|
||||
"demoFootNote": "결재 대기함에서 문서를 선택하면 실제 승인/반려가 가능합니다.",
|
||||
"demoHint": "결재 문서가 지정되지 않았습니다. 결재 대기함에서 문서를 열어 주세요.",
|
||||
"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": {
|
||||
"boq": {
|
||||
@ -1887,7 +1961,21 @@
|
||||
"errRequired": "부서명을 입력하세요.",
|
||||
"loadError": "부서 목록을 불러오지 못했습니다.",
|
||||
"emptyTitle": "등록된 부서가 없습니다",
|
||||
"emptyDesc": "최상위 부서를 추가해 주세요."
|
||||
"emptyDesc": "최상위 부서를 추가해 주세요.",
|
||||
"searchPlaceholder": "부서명 검색",
|
||||
"searchNoResult": "검색 결과가 없습니다.",
|
||||
"treeAria": "부서 조직도 트리",
|
||||
"expand": "펼치기",
|
||||
"collapse": "접기",
|
||||
"expandAll": "모두 펼치기",
|
||||
"collapseAll": "모두 접기",
|
||||
"selectHint": "좌측에서 부서를 선택하세요.",
|
||||
"colId": "부서 ID",
|
||||
"colParent": "상위 부서",
|
||||
"topLevel": "최상위",
|
||||
"colChildCount": "하위 부서 수",
|
||||
"childCountValue": "{{n}}개",
|
||||
"errHasChildren": "하위 부서가 있어 삭제할 수 없습니다."
|
||||
},
|
||||
"errorLog": {
|
||||
"title": "오류 로그",
|
||||
@ -2000,5 +2088,57 @@
|
||||
"home": {
|
||||
"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": "저장"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1080,7 +1080,20 @@
|
||||
"rentalSubtotal": "租金小计",
|
||||
"rentalTotal": "租金合计 ({{days}}天)",
|
||||
"deposit": "管理费押金(另计)",
|
||||
"ruleset": "(规则集 {{v}})"
|
||||
"ruleset": "(规则集 {{v}})",
|
||||
"openDetail": "打开 {{event}} 分配详情",
|
||||
"detailTitle": "分配详情",
|
||||
"edit": "修改",
|
||||
"dHall": "展厅",
|
||||
"dPeriod": "占用期间",
|
||||
"dStatus": "状态",
|
||||
"dNote": "备注",
|
||||
"primaryTag": "主展厅",
|
||||
"dConflict": "冲突情况",
|
||||
"dNoConflict": "没有重叠的分配。",
|
||||
"dQuote": "自动报价摘要",
|
||||
"dQuoteNoPeriod": "无占用期间,无法计算报价。",
|
||||
"dQuoteFail": "无法加载报价。"
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
@ -1701,7 +1714,68 @@
|
||||
"demoFootNote": "从审批收件箱选择文档即可批准或驳回。",
|
||||
"demoHint": "未指定审批文档,请从审批收件箱打开。",
|
||||
"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": {
|
||||
"boq": {
|
||||
@ -1887,7 +1961,21 @@
|
||||
"errRequired": "请输入部门名称。",
|
||||
"loadError": "无法加载部门列表。",
|
||||
"emptyTitle": "尚无已登记部门",
|
||||
"emptyDesc": "请添加顶级部门。"
|
||||
"emptyDesc": "请添加顶级部门。",
|
||||
"searchPlaceholder": "搜索部门",
|
||||
"searchNoResult": "无匹配部门。",
|
||||
"treeAria": "部门组织树",
|
||||
"expand": "展开",
|
||||
"collapse": "折叠",
|
||||
"expandAll": "全部展开",
|
||||
"collapseAll": "全部折叠",
|
||||
"selectHint": "请从左侧选择一个部门。",
|
||||
"colId": "部门ID",
|
||||
"colParent": "上级部门",
|
||||
"topLevel": "最高级",
|
||||
"colChildCount": "下级部门数",
|
||||
"childCountValue": "{{n}}个",
|
||||
"errHasChildren": "存在下级部门,无法删除。"
|
||||
},
|
||||
"errorLog": {
|
||||
"title": "错误日志",
|
||||
@ -2000,5 +2088,57 @@
|
||||
"home": {
|
||||
"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": "保存"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,47 +1,187 @@
|
||||
/*
|
||||
* W12 시스템관리 — 조직(부서 트리) 관리.
|
||||
* 정본: GET/POST /api/admin/depts · DELETE /{id} (DeptController). 트리 응답(children 중첩), upsert 저장.
|
||||
* W12 시스템관리 — 조직(부서) 관리. WISE 조직도(UIWS system/dept·org) 이식.
|
||||
* 좌: 부서 계층 트리(상위→하위 들여쓰기·펼침/접힘·정렬/사용 배지·선택 강조·통합검색·자동펼침).
|
||||
* 우: 선택 부서 상세(부서명·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 { 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 {
|
||||
IconPlus, IconUsers, IconSearch, IconChevronRight, IconChevronDown,
|
||||
} from '../../components/ui/icons';
|
||||
import { errMessage, useToast } from '../work/workShared';
|
||||
import { deptApi, type DeptNode, type DeptSaveBody } from './systemBasicsApi';
|
||||
import './admin-modules.css';
|
||||
import './dept-admin.css';
|
||||
|
||||
interface FlatDept { node: DeptNode; depth: number }
|
||||
|
||||
function flatten(nodes: DeptNode[], depth: number, out: FlatDept[]) {
|
||||
for (const n of nodes) {
|
||||
out.push({ node: n, depth });
|
||||
if (n.children && n.children.length) flatten(n.children, depth + 1, out);
|
||||
}
|
||||
// ── 트리 유틸 ─────────────────────────────────────────────
|
||||
/** id → 노드 인덱스(상세·경로·부모명 조회). */
|
||||
function indexNodes(nodes: DeptNode[]): Map<string, DeptNode> {
|
||||
const map = new Map<string, DeptNode>();
|
||||
const walk = (ns: DeptNode[]) => {
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const q = useQuery({ queryKey: ['admin-depts'], queryFn: deptApi.tree, retry: false });
|
||||
const [editing, setEditing] = useState<{ node: DeptNode | null; parentId: string | null } | undefined>(undefined);
|
||||
|
||||
const [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 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({
|
||||
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)),
|
||||
});
|
||||
const delM = useMutation({
|
||||
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)),
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="kx-page kx-adm">
|
||||
<header className="kx-adm__head">
|
||||
@ -49,79 +189,206 @@ export function DeptAdminPage() {
|
||||
<h1 className="kx-adm__title">{t('dept.title')}</h1>
|
||||
<p className="kx-adm__subtitle">{t('dept.subtitle')}</p>
|
||||
</div>
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing({ node: null, parentId: null })}>
|
||||
{t('dept.addRoot')}
|
||||
</Button>
|
||||
<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')}
|
||||
</Button>
|
||||
</div>
|
||||
</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.isLoading && !q.isError && (
|
||||
<section className="kx-card kx-adm-table-card">
|
||||
<>
|
||||
<div className="kx-dept-toolbar">
|
||||
<span className="kx-dept-search">
|
||||
<IconSearch size={16} className="kx-dept-search__icon" />
|
||||
<input
|
||||
className="kx-dept-search__input"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('dept.searchPlaceholder')}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label={t('dept.searchPlaceholder')}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{flat.length === 0 ? (
|
||||
<EmptyState title={t('dept.emptyTitle')} description={t('dept.emptyDesc')} />
|
||||
<section className="kx-card">
|
||||
<EmptyState title={t('dept.emptyTitle')} description={t('dept.emptyDesc')} />
|
||||
</section>
|
||||
) : (
|
||||
<ul className="kx-adm-tree">
|
||||
{flat.map(({ node, depth }) => (
|
||||
<li key={node.id} className="kx-adm-tree__row" style={{ paddingLeft: 12 + depth * 20 }}>
|
||||
<span className="kx-adm-tree__name">
|
||||
{node.deptName}
|
||||
{node.useYn === 'N' && <span className="kx-adm-muted"> · {t('sysadmin.noUse')}</span>}
|
||||
</span>
|
||||
<span className="kx-adm-tree__actions">
|
||||
<Button variant="ghost" onClick={() => setEditing({ node: null, parentId: node.id })}>{t('dept.addChild')}</Button>
|
||||
<Button variant="ghost" onClick={() => setEditing({ node, parentId: node.parentId })}>{t('sysadmin.edit')}</Button>
|
||||
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
|
||||
onClick={() => { if (window.confirm(t('dept.deleteConfirm', { name: node.deptName }))) delM.mutate(node.id); }}>
|
||||
{t('sysadmin.delete')}
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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}
|
||||
onClick={() => onDelete(selectedNode)}>
|
||||
{t('sysadmin.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="kx-dept-detail__list">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing !== undefined && (
|
||||
<DeptModal node={editing.node} parentId={editing.parentId} saving={saveM.isPending}
|
||||
onClose={() => setEditing(undefined)} onSubmit={(b) => saveM.mutate(b)} />
|
||||
{modal && (
|
||||
<DeptModal
|
||||
state={modal}
|
||||
allDepts={flat}
|
||||
nextSort={nextSort}
|
||||
saving={saveM.isPending}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={(b) => saveM.mutate(b)}
|
||||
/>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeptModal({ node, parentId, saving, onClose, onSubmit }: {
|
||||
node: DeptNode | null; parentId: string | null; saving: boolean;
|
||||
onClose: () => void; onSubmit: (b: DeptSaveBody) => void;
|
||||
// ── 추가/수정 모달(상위 부서 선택 포함) ──
|
||||
function DeptModal({ state, allDepts, nextSort, saving, onClose, onSubmit }: {
|
||||
state: NonNullable<ModalState>;
|
||||
allDepts: DeptNode[];
|
||||
nextSort: number;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (b: DeptSaveBody) => void;
|
||||
}) {
|
||||
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 [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 [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();
|
||||
if (!deptName.trim()) { setError(t('dept.errRequired')); return; }
|
||||
onSubmit({
|
||||
id: node?.id ?? null,
|
||||
deptName: deptName.trim(),
|
||||
parentId: node ? node.parentId : parentId,
|
||||
sortOrder,
|
||||
useYn,
|
||||
});
|
||||
onSubmit({ id: node?.id ?? null, deptName: deptName.trim(), parentId, sortOrder, useYn });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-adm-modal" role="dialog" aria-modal="true">
|
||||
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||
<h2 className="kx-adm-modal__title">{isNew ? t('dept.add') : t('dept.edit')}</h2>
|
||||
<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">
|
||||
<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">
|
||||
<span className="kx-adm-field__label">{t('dept.name')}</span>
|
||||
<input className="kx-adm-field__input" value={deptName} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
271
src/frontend/src/screens/admin/HolidayAdminPage.tsx
Normal file
271
src/frontend/src/screens/admin/HolidayAdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -106,21 +106,22 @@
|
||||
.kx-adm-audit { width: 100%; border-collapse: collapse; }
|
||||
.kx-adm-audit th,
|
||||
.kx-adm-audit td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: var(--fs-body);
|
||||
vertical-align: top;
|
||||
}
|
||||
.kx-adm-audit thead th {
|
||||
background: var(--color-neutral-100);
|
||||
background: var(--color-neutral-050);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: var(--border-card);
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: 2px solid var(--color-neutral-200);
|
||||
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-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-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 {
|
||||
background: var(--color-neutral-100);
|
||||
background: var(--color-neutral-050);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
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-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); }
|
||||
@ -343,15 +347,18 @@
|
||||
}
|
||||
.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 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 {
|
||||
background: var(--color-neutral-100);
|
||||
background: var(--color-neutral-050);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
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.is-selected { background: var(--color-primary-050); }
|
||||
.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 th,
|
||||
.kx-adm-tbl td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: var(--fs-body);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.kx-adm-tbl thead th {
|
||||
background: var(--color-neutral-100);
|
||||
background: var(--color-neutral-050);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: var(--border-card);
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: 2px solid var(--color-neutral-200);
|
||||
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.is-selected { background: var(--color-primary-050); }
|
||||
.kx-adm-tbl__strong { font-weight: 600; color: var(--color-neutral-900); }
|
||||
|
||||
249
src/frontend/src/screens/admin/dept-admin.css
Normal file
249
src/frontend/src/screens/admin/dept-admin.css
Normal 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; }
|
||||
}
|
||||
48
src/frontend/src/screens/admin/holidayApi.ts
Normal file
48
src/frontend/src/screens/admin/holidayApi.ts
Normal 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;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
@ -17,7 +18,9 @@ type Tab = 'nlquery' | 'rules';
|
||||
|
||||
export function AiConsolePanel() {
|
||||
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 (
|
||||
<section className="kx-card kx-aicon" aria-label={t('ai.console.title')}>
|
||||
|
||||
283
src/frontend/src/screens/approval/ApprovalComposePanel.tsx
Normal file
283
src/frontend/src/screens/approval/ApprovalComposePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/frontend/src/screens/approval/ApprovalLineEditor.tsx
Normal file
104
src/frontend/src/screens/approval/ApprovalLineEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconSearch } from '../../components/ui/icons';
|
||||
import { approvalApi, type ApprovalStatus } from './approvalApi';
|
||||
import { IconPlus, IconSearch } from '../../components/ui/icons';
|
||||
import { approvalApi, type ApprovalBox, type ApprovalListItem, type ApprovalStatus } from './approvalApi';
|
||||
import { ApprovalComposePanel } from './ApprovalComposePanel';
|
||||
import './approval-queue.css';
|
||||
|
||||
/*
|
||||
* SCR-10 결재 대기열(홀매니저·결재자). §5B 공통 전자결재(F028) 실배선.
|
||||
* - 샘플 큐를 실 결재함 API(box=PENDING: 내가 현재 단계 결재자인 진행중 문서)로 전환.
|
||||
* - '검수/열기' → ReviewDetailsPage(승인/반려 실동작). 라우트 미변경: approvalId 쿼리로 전달.
|
||||
* SCR-10 결재함(홀매니저·기안자·결재자). §5B 공통 전자결재(F028) 실배선 — WISE 결재함 구조.
|
||||
* - 탭: 결재할 문서(PENDING) · 상신한 문서(DRAFTED) · 반려(REJECTED, 내 기안) · 완료(DONE).
|
||||
* - 기안: 기안 작성 + 결재선 지정 패널(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 {
|
||||
switch (s) {
|
||||
case 'APPROVED':
|
||||
@ -29,55 +38,140 @@ function statusTone(s: ApprovalStatus): string {
|
||||
export function ManagerApprovalQueuePage() {
|
||||
const { eventId = '', hallId = 'H7', booth = 'A-101' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
|
||||
const [tab, setTab] = useState<TabKey>('PENDING');
|
||||
const [keyword, setKeyword] = 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({
|
||||
queryKey: ['approvals', 'PENDING', term],
|
||||
queryFn: () => approvalApi.list({ box: 'PENDING', keyword: term || undefined, page: 0, size: 100 }),
|
||||
queryKey: ['approvals', box, term],
|
||||
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 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 (
|
||||
<div className="kx-aq">
|
||||
<header className="kx-aq__head">
|
||||
<div className="kx-aq__head-top">
|
||||
<div className="kx-aq__title-row">
|
||||
<h1>{t('approval.queueTitle')}</h1>
|
||||
<h1>{t('approval.boxTitle')}</h1>
|
||||
</div>
|
||||
<div className="kx-aq__head-actions">
|
||||
<form
|
||||
className="kx-aq__search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setTerm(keyword.trim());
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
placeholder={t('approval.searchPlaceholder')}
|
||||
aria-label={t('approval.search')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={openCompose}>
|
||||
{t('approval.compose')}
|
||||
</Button>
|
||||
</div>
|
||||
<form
|
||||
className="kx-aq__search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setTerm(keyword.trim());
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
placeholder={t('approval.searchPlaceholder')}
|
||||
aria-label={t('approval.search')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</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 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>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<h2>{t('approval.pendingList')}</h2>
|
||||
<h2>{t(`approval.tab.${tab}`)}</h2>
|
||||
<span className="kx-aq__count tnum">{rows.length}</span>
|
||||
</div>
|
||||
<div className="kx-aq__table-wrap">
|
||||
{listQ.isLoading ? (
|
||||
@ -89,7 +183,7 @@ export function ManagerApprovalQueuePage() {
|
||||
) : listQ.isError ? (
|
||||
<ErrorState onRetry={() => listQ.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title={t('approval.emptyPending')} />
|
||||
<EmptyState title={t(`approval.empty.${tab}`)} />
|
||||
) : (
|
||||
<table className="kx-aq__table">
|
||||
<thead>
|
||||
@ -110,25 +204,18 @@ export function ManagerApprovalQueuePage() {
|
||||
<div className="kx-aq__event">{r.title}</div>
|
||||
{r.refId ? <div className="kx-aq__booth tnum">{r.refId}</div> : null}
|
||||
</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 tnum">{(r.submittedAt ?? '').slice(0, 10)}</td>
|
||||
<td className="kx-aq__muted tnum">{(r.submittedAt ?? '').slice(0, 10) || '-'}</td>
|
||||
<td className="tnum">
|
||||
{r.curStep}/{r.totalSteps}
|
||||
{r.totalSteps > 0 ? `${r.curStep}/${r.totalSteps}` : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`kx-aq__st ${statusTone(r.status)}`}>{statusLabel(r.status)}</span>
|
||||
</td>
|
||||
<td className="kx-aq__col-act">
|
||||
<button
|
||||
className="kx-aq__review"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/events/${eventId}/halls/${hallId}/booths/${r.refId ?? booth}/review?approvalId=${encodeURIComponent(r.id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('approval.open')}
|
||||
<button className="kx-aq__review" onClick={() => onRowOpen(r)}>
|
||||
{r.status === 'REJECTED' ? t('approval.resubmitBtn') : t('approval.open')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -7,6 +7,7 @@ import { AiLabel } from '../../components/ui/Badge';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconClose,
|
||||
IconDownload,
|
||||
IconExpand,
|
||||
IconMinus,
|
||||
@ -15,18 +16,22 @@ import {
|
||||
} from '../../components/ui/icons';
|
||||
import { approvalApi, type ApprovalLine } from './approvalApi';
|
||||
import './review-details.css';
|
||||
import './approval-form.css';
|
||||
|
||||
/*
|
||||
* SCR-11 검수 상세. §5B 공통 전자결재(F028) 실배선.
|
||||
* - 좌: 문서/도면 프리뷰(참고용 샘플 캔버스 유지) / 우: 실 결재선·이력·의견 + 승인/반려(실동작).
|
||||
* - 좌: 문서/도면 프리뷰(참고용 샘플 캔버스 유지) / 우: 실 결재선 타임라인·이력 + 승인/반려(실동작).
|
||||
* - approvalId 쿼리 존재 시 실 결재 문서 로드·처리(승인/반려는 현재 단계 결재자만 백엔드 403).
|
||||
* 미존재 시 기존 데모(도면·AI 위반 오버레이) 유지 — 라우트 하위호환.
|
||||
* 반려는 사유 입력 모달(사유 필수). 반려된 내 기안은 재상신 링크(결재함 재상신 패널) 노출.
|
||||
* 미존재 시 기존 데모(도면·AI 오버레이) 유지 — 라우트 하위호환.
|
||||
*/
|
||||
const MOCK = `${import.meta.env.BASE_URL}mock/review_details`;
|
||||
const BLUEPRINT = `${MOCK}/img1.jpg`;
|
||||
|
||||
const TABS = ['도면', '예상 사진 (참고용)', '버전 비교'];
|
||||
|
||||
type ActionKind = 'approve' | 'reject';
|
||||
|
||||
function lineTone(s: ApprovalLine['lineStatus']): string {
|
||||
switch (s) {
|
||||
case 'CURRENT':
|
||||
@ -51,8 +56,8 @@ export function ReviewDetailsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [overlay, setOverlay] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [actionKind, setActionKind] = useState<ActionKind | null>(null);
|
||||
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['approval', approvalId],
|
||||
@ -66,20 +71,20 @@ export function ReviewDetailsPage() {
|
||||
};
|
||||
|
||||
const approveM = useMutation({
|
||||
mutationFn: () => approvalApi.approve(approvalId as string, comment.trim() || undefined),
|
||||
mutationFn: (comment: string) => approvalApi.approve(approvalId as string, comment.trim() || undefined),
|
||||
onSuccess: () => {
|
||||
setNotice(t('approval.approvedDone'));
|
||||
setComment('');
|
||||
setActionKind(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => setNotice(e.message),
|
||||
});
|
||||
|
||||
const rejectM = useMutation({
|
||||
mutationFn: () => approvalApi.reject(approvalId as string, comment.trim()),
|
||||
mutationFn: (comment: string) => approvalApi.reject(approvalId as string, comment.trim()),
|
||||
onSuccess: () => {
|
||||
setNotice(t('approval.rejectedDone'));
|
||||
setComment('');
|
||||
setActionKind(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => setNotice(e.message),
|
||||
@ -89,12 +94,17 @@ export function ReviewDetailsPage() {
|
||||
const busy = approveM.isPending || rejectM.isPending;
|
||||
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 (
|
||||
<div className="kx-rev">
|
||||
<header className="kx-rev__head">
|
||||
<div>
|
||||
<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">
|
||||
<IconChevronRight size={14} />
|
||||
</span>
|
||||
@ -175,7 +185,7 @@ export function ReviewDetailsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 우: 실 결재선·이력·의견 */}
|
||||
{/* 우: 실 결재선 타임라인·이력·의견 */}
|
||||
<aside className="kx-rev__panel" aria-label={t('approval.lineAria')}>
|
||||
{!approvalId ? (
|
||||
<div className="kx-rev__ai-card">
|
||||
@ -249,7 +259,7 @@ export function ReviewDetailsPage() {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 — 실 승인/반려(현재 단계 결재자만) */}
|
||||
{/* 하단 액션 — 승인/반려(현재 단계 결재자) · 재상신(반려된 내 기안) */}
|
||||
<footer className="kx-rev__footer">
|
||||
<div className="kx-rev__foot-left">
|
||||
<p className="kx-rev__foot-note">
|
||||
@ -258,32 +268,108 @@ export function ReviewDetailsPage() {
|
||||
: approvalId && d
|
||||
? d.canAct
|
||||
? t('approval.canActHint')
|
||||
: t('approval.notApproverHint')
|
||||
: d.canResubmit
|
||||
? t('approval.canResubmitHint')
|
||||
: t('approval.notApproverHint')
|
||||
: t('approval.demoFootNote')}
|
||||
</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 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
|
||||
variant="danger"
|
||||
disabled={!approvalId || !d?.canAct || busy || comment.trim().length === 0}
|
||||
onClick={() => rejectM.mutate()}
|
||||
disabled={!approvalId || !d?.canAct || busy}
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setActionKind('reject');
|
||||
}}
|
||||
>
|
||||
{t('approval.reject')}
|
||||
</Button>
|
||||
<Button disabled={!approvalId || !d?.canAct || busy} onClick={() => approveM.mutate()}>
|
||||
<Button
|
||||
disabled={!approvalId || !d?.canAct || busy}
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setActionKind('approve');
|
||||
}}
|
||||
>
|
||||
{t('approval.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
346
src/frontend/src/screens/approval/approval-form.css
Normal file
346
src/frontend/src/screens/approval/approval-form.css
Normal 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);
|
||||
}
|
||||
@ -129,17 +129,20 @@
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
.kx-aq__table thead {
|
||||
background: var(--color-primary-050);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.kx-aq__table th {
|
||||
text-align: left;
|
||||
padding: var(--space-3);
|
||||
padding: 10px 12px;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
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 {
|
||||
color: var(--color-ai-accent) !important;
|
||||
@ -148,8 +151,8 @@
|
||||
text-align: right;
|
||||
}
|
||||
.kx-aq__table td {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.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 실배선 — 상태 배지·스켈레톤 */
|
||||
.kx-aq__skel {
|
||||
display: flex;
|
||||
|
||||
@ -1046,24 +1046,30 @@
|
||||
.kx-boq__scroll {
|
||||
overflow: auto;
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.kx-boq__table {
|
||||
width: 100%;
|
||||
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 td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--color-border-subtle, #eaecf0);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.kx-boq__table thead th {
|
||||
background: var(--color-surface-subtle, #f9fafb);
|
||||
background: var(--color-neutral-050);
|
||||
font-size: var(--fs-caption);
|
||||
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;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@ -1000,17 +1000,19 @@
|
||||
}
|
||||
.kx-i18n-table thead th {
|
||||
text-align: left;
|
||||
padding: 10px var(--space-3);
|
||||
padding: 10px 12px;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-neutral-500);
|
||||
background: var(--color-neutral-050);
|
||||
border-bottom: var(--border-card);
|
||||
border-bottom: 2px solid var(--color-neutral-200);
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.kx-i18n-table tbody td {
|
||||
padding: 10px var(--space-3);
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
color: var(--color-neutral-700);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@ -211,24 +211,30 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* 테이블 */
|
||||
/* 테이블 — 그리드 표준(Nifty Advanced headers, shared.css 정의와 동일) */
|
||||
.kx-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-table th {
|
||||
.kx-table thead th {
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
padding: 10px 12px;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-neutral-500);
|
||||
background: var(--color-neutral-050);
|
||||
border-bottom: 2px solid var(--color-neutral-200);
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.kx-table td {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
height: var(--row-h);
|
||||
border-top: 1px solid var(--color-neutral-100);
|
||||
.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);
|
||||
|
||||
@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { Offcanvas } from '../../components/ui/Offcanvas';
|
||||
import { IconWarning } from '../../components/ui/icons';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import {
|
||||
@ -61,6 +62,9 @@ export function HallAssignmentPage() {
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const [conflictHits, setConflictHits] = useState<Conflict[]>([]);
|
||||
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 assignQ = useQuery({
|
||||
@ -121,6 +125,7 @@ export function HallAssignmentPage() {
|
||||
mutationFn: (id: string) => hallAssignApi.release(id),
|
||||
onSuccess: () => {
|
||||
setToast(t('hallassign.releasedToast'));
|
||||
setDetailOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['hall-assignments'] });
|
||||
},
|
||||
onError: () => setToast(t('hallassign.releaseFail')),
|
||||
@ -133,6 +138,27 @@ export function HallAssignmentPage() {
|
||||
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) {
|
||||
const ev = eventsQ.data?.find((e) => e.id === eventId);
|
||||
setForm((f) => ({
|
||||
@ -211,10 +237,24 @@ export function HallAssignmentPage() {
|
||||
className={`kx-hall__chip${conflict ? ' is-conflict' : ''}${a.primary ? ' is-primary' : ''}`}
|
||||
title={`${a.occupyStart ?? '?'} ~ ${a.occupyEnd ?? '?'}`}
|
||||
>
|
||||
{conflict && <IconWarning size={13} />}
|
||||
<span className="kx-hall__chiptext">
|
||||
{a.eventName}
|
||||
<em>{a.occupyStart} ~ {a.occupyEnd}</em>
|
||||
<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} />}
|
||||
<span className="kx-hall__chiptext">
|
||||
{a.eventName}
|
||||
<em>{a.occupyStart} ~ {a.occupyEnd}</em>
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -309,10 +349,138 @@ export function HallAssignmentPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 배정 상세 본문 — 기간·홀·상태·충돌 행사·자동 견적 요약. */
|
||||
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[] }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -93,6 +93,20 @@
|
||||
border-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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -245,6 +259,106 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
|
||||
@ -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() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<ShowcaseTab>('ongoing');
|
||||
const [tab, setTab] = useState<ShowcaseTab>('thisMonth');
|
||||
const railRef = useRef<HTMLDivElement | null>(null);
|
||||
const [atStart, setAtStart] = useState(true);
|
||||
const [atEnd, setAtEnd] = useState(false);
|
||||
|
||||
@ -369,12 +369,12 @@
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
/* ── ④ 본문 2열 그리드 ── */
|
||||
/* ── ④ 본문 2열 그리드 ── 좌우 컬럼 하단 정렬(stretch): 최근 활동↔진행 중 전시 높이 일치 ── */
|
||||
.kx-home__body {
|
||||
display: grid;
|
||||
grid-template-columns: 8fr 4fr;
|
||||
gap: var(--space-5);
|
||||
align-items: start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.kx-home__col-main,
|
||||
.kx-home__col-side {
|
||||
@ -383,6 +383,11 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
/* 각 컬럼 마지막 카드가 남은 높이를 채워 양 컬럼 하단선을 맞춘다(공백 없는 반응형). */
|
||||
.kx-home__col-main > :last-child,
|
||||
.kx-home__col-side > :last-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* 캘린더 */
|
||||
.kx-home__cal-head {
|
||||
|
||||
166
src/frontend/src/screens/public/AgencyTrackPage.tsx
Normal file
166
src/frontend/src/screens/public/AgencyTrackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
src/frontend/src/screens/public/BusinessTrackPage.tsx
Normal file
184
src/frontend/src/screens/public/BusinessTrackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/frontend/src/screens/public/PublicGatewayPage.tsx
Normal file
111
src/frontend/src/screens/public/PublicGatewayPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -2,11 +2,12 @@
|
||||
* 공개 홍보 사이트 공통 셸 — 헤더 GNB + 푸터. (AppShell 미사용, 비로그인 공개 영역)
|
||||
* 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 { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { IconMenu, IconClose, IconSearch } from './publicIcons';
|
||||
import { LanguageSwitcher } from '../../components/ui/LanguageSwitcher';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import './public.css';
|
||||
|
||||
const GNB = [
|
||||
@ -16,10 +17,96 @@ const GNB = [
|
||||
{ key: 'transport', i18n: 'shell.nav.transport' },
|
||||
] 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 {
|
||||
children: ReactNode;
|
||||
/** 현재 활성 GNB 키 (하이라이트) */
|
||||
active?: string;
|
||||
/** 현재 트랙 (§3C — 상단 스위처 활성 탭). 관문은 미지정. */
|
||||
track?: PublicTrack;
|
||||
/** 헤더 우측 주요 CTA 라벨 (없으면 사전등록 기본) */
|
||||
cta?: string;
|
||||
/** 헤더에 검색 노출 여부 */
|
||||
@ -31,6 +118,7 @@ interface PublicShellProps {
|
||||
export function PublicShell({
|
||||
children,
|
||||
active,
|
||||
track,
|
||||
cta,
|
||||
search = true,
|
||||
brand = 'KINTEX',
|
||||
@ -41,6 +129,7 @@ export function PublicShell({
|
||||
|
||||
return (
|
||||
<div className="kxp">
|
||||
<TrackSwitcher track={track} />
|
||||
<header className="kxp-header">
|
||||
<div className="kxp-header__inner">
|
||||
<div className="kxp-header__left">
|
||||
|
||||
180
src/frontend/src/screens/public/VisitorTrackPage.tsx
Normal file
180
src/frontend/src/screens/public/VisitorTrackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,12 @@
|
||||
* 전부 비로그인 공개 라우트 전제. 인증 가드 없이 마운트한다.
|
||||
*/
|
||||
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 { PublicEventDetailPage } from './PublicEventDetailPage'; // SCR-P2 /public/events/:eventId
|
||||
export { PublicFloorplanPage } from './PublicFloorplanPage'; // SCR-P3 /public/events/:eventId/floorplan
|
||||
|
||||
@ -2582,10 +2582,12 @@
|
||||
.kxp-ticktable th {
|
||||
text-align: left;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-neutral-500);
|
||||
padding: 12px 8px;
|
||||
background: var(--color-neutral-050);
|
||||
border-bottom: 2px solid var(--color-neutral-200);
|
||||
}
|
||||
.kxp-ticktable td {
|
||||
padding: 14px 8px;
|
||||
@ -2787,3 +2789,424 @@
|
||||
z-index: 55;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -40,6 +40,15 @@ export interface PublicFloorplan {
|
||||
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) ────────────────────
|
||||
export interface InquiryPayload {
|
||||
inquiryType?: string;
|
||||
@ -121,4 +130,8 @@ export const publicApi = {
|
||||
/** cms 팀 계약 — shape 확정 전 관용 처리. */
|
||||
getMicrosite: (exhibitorId: string) =>
|
||||
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),
|
||||
};
|
||||
|
||||
137
src/frontend/src/screens/public/trackShared.tsx
Normal file
137
src/frontend/src/screens/public/trackShared.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -9,8 +9,6 @@ import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconGrid,
|
||||
IconUsers,
|
||||
} from '../../components/ui/icons';
|
||||
@ -20,17 +18,37 @@ import {
|
||||
EVENT_YEARS,
|
||||
STATUS_LABEL,
|
||||
eventDaysFor,
|
||||
eventsByDateFor,
|
||||
hallUtilizationFor,
|
||||
type EventCategory,
|
||||
type EventStatus,
|
||||
type HallUtilRow,
|
||||
type ScheduleEvent,
|
||||
} from './sampleSchedule';
|
||||
import KxCalendarView, { type KxCalendarItem } from './KxCalendarView';
|
||||
import { scheduleApi } from './scheduleApi';
|
||||
import { hallAssignApi } from '../hallassign/hallAssignApi';
|
||||
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). */
|
||||
function isDegradable(err: unknown): boolean {
|
||||
return (
|
||||
@ -149,6 +167,18 @@ export function ExhibitionSchedulePage() {
|
||||
// 목록은 최신 시작일 우선 정렬
|
||||
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 (
|
||||
<div className="kx-sched">
|
||||
<header className="kx-sched__head">
|
||||
@ -252,7 +282,12 @@ export function ExhibitionSchedulePage() {
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<MonthCalendar events={filtered} />
|
||||
<KxCalendarView
|
||||
items={calItems}
|
||||
legend={catLegend}
|
||||
today={TODAY_YMD}
|
||||
initialDate={TODAY_YMD}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className="kx-sched__aside">
|
||||
@ -266,78 +301,6 @@ export function ExhibitionSchedulePage() {
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
685
src/frontend/src/screens/schedule/KxCalendarView.tsx
Normal file
685
src/frontend/src/screens/schedule/KxCalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
541
src/frontend/src/screens/schedule/calendarView.css
Normal file
541
src/frontend/src/screens/schedule/calendarView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -33,9 +33,16 @@
|
||||
|
||||
.kx-sched__split {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
/* 소유자 피드백①: 어중간한 2:1 → 균형 50:50. 달력 7열이 반폭에서 답답하지 않게 좌측 최소폭 확보. */
|
||||
grid-template-columns: minmax(0, 1fr) 1fr;
|
||||
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 {
|
||||
display: flex;
|
||||
@ -322,7 +329,8 @@
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.kx-sched__split {
|
||||
grid-template-columns: 1fr;
|
||||
/* 단일 컬럼도 min-width:0 확보(7열 그리드가 뷰포트를 넘지 않게). */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
|
||||
@ -139,19 +139,55 @@
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.kx-table--zebra tbody tr:nth-child(even) {
|
||||
background: var(--zebra);
|
||||
}
|
||||
.kx-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
/* 숫자열: 우정렬 + 등폭 숫자(정렬 통일) */
|
||||
.kx-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 가로 막대 리스트 */
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* ★ 갭: AI 자연어 검색 엔드포인트·크로스모듈(행사/부스/업체) 검색 부재 → AI 토글은 UI 표기(비활성 안내). 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { searchApi } from '../../api/endpoints';
|
||||
import type { SearchResultItem } from '../../api/types';
|
||||
@ -25,9 +26,46 @@ const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> =
|
||||
MEETING: { label: '회의록', Icon: IconCalendar },
|
||||
REPORT: { 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() {
|
||||
const navigate = useNavigate();
|
||||
const [input, setInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [aiMode, setAiMode] = useState(false);
|
||||
@ -73,7 +111,7 @@ export function SearchPage() {
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">통합검색</h1>
|
||||
<p className="kx-work__subtitle">업무일지·공지·회의록·보고·의견 크로스 검색</p>
|
||||
<p className="kx-work__subtitle">업무일지·공지·회의록·보고·의견 + 행사·업체·옥션·부스·리드·콘텐츠 크로스 검색</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -156,8 +194,18 @@ export function SearchPage() {
|
||||
{filtered.map((r: SearchResultItem) => {
|
||||
const meta = TYPE_META[r.type] ?? { label: r.type, Icon: IconDocument };
|
||||
const Icon = meta.Icon;
|
||||
const route = routeFor(r.type, r.id);
|
||||
const go = () => route && navigate(route);
|
||||
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">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
|
||||
@ -8,12 +8,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { scheduleWorkApi } from '../../api/endpoints';
|
||||
import type { ScheduleDto, ScheduleSaveRequest } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconChevronLeft, IconChevronRight, IconPlus } from '../../components/ui/icons';
|
||||
import { EmptyState, ErrorState } from '../../components/ui/States';
|
||||
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 './work.css';
|
||||
|
||||
const WD = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
milestone: '#0066B3',
|
||||
auction: '#6D4AFF',
|
||||
@ -36,11 +37,34 @@ function colorOf(s: ScheduleDto): string {
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
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 [composing, setComposing] = useState(false);
|
||||
const [form, setForm] = useState<ScheduleSaveRequest>({
|
||||
@ -53,29 +77,23 @@ export function WorkSchedulePage() {
|
||||
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({
|
||||
queryKey: ['schedules', ymd(grid.start), ymd(grid.end)],
|
||||
queryKey: ['schedules', range.fromDate, range.toDate],
|
||||
queryFn: () =>
|
||||
scheduleWorkApi.list({
|
||||
fromAt: `${ymd(grid.start)}T00:00:00`,
|
||||
toAt: `${ymd(grid.end)}T23:59:59`,
|
||||
fromAt: `${range.fromDate}T00:00:00`,
|
||||
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 map = new Map<string, ScheduleDto[]>();
|
||||
for (const s of listQ.data ?? []) {
|
||||
@ -88,6 +106,16 @@ export function WorkSchedulePage() {
|
||||
return map;
|
||||
}, [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({
|
||||
mutationFn: (body: ScheduleSaveRequest) => scheduleWorkApi.create(body),
|
||||
onSuccess: () => {
|
||||
@ -107,7 +135,6 @@ export function WorkSchedulePage() {
|
||||
});
|
||||
|
||||
const agenda = byDay.get(selDate) ?? [];
|
||||
const todayKey = ymd(today);
|
||||
|
||||
function openCompose() {
|
||||
setForm({
|
||||
@ -127,89 +154,22 @@ export function WorkSchedulePage() {
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">일정</h1>
|
||||
<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>
|
||||
<p className="kx-work__subtitle">개인·부서 업무 일정 · 마일스톤/옥션/반입 통합 뷰 · 월/주/일</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-cal">
|
||||
<div>
|
||||
<div className="kx-cal__grid" role="grid" aria-label="월 캘린더">
|
||||
<div className="kx-cal__weekhead" role="row">
|
||||
{WD.map((w, i) => (
|
||||
<div key={w} className={`kx-cal__wd ${i === 0 ? 'kx-cal__wd--sun' : ''}`} role="columnheader">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, wk) => (
|
||||
<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>
|
||||
<KxCalendarView
|
||||
items={calItems}
|
||||
legend={legend}
|
||||
selectedDate={selDate}
|
||||
loading={listQ.isLoading}
|
||||
holidays={holidays}
|
||||
onRangeChange={(r) => setRange(r)}
|
||||
onDateClick={(d) => setSelDate(d)}
|
||||
onItemClick={(it) => setSelDate(it.startDate)}
|
||||
/>
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
|
||||
@ -29,10 +29,10 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 목록/상세 2-Pane 분할 (worklog·opinion) ── */
|
||||
/* ── 목록/상세 2-Pane 분할 (worklog·opinion) — 좌우 50:50(소유자 지시 2026-07-12) ── */
|
||||
.kx-split {
|
||||
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);
|
||||
align-items: start;
|
||||
}
|
||||
@ -102,18 +102,18 @@
|
||||
z-index: 1;
|
||||
background: var(--color-neutral-050);
|
||||
text-align: left;
|
||||
padding: 0 var(--space-3);
|
||||
height: 40px;
|
||||
padding: 10px 12px;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-neutral-500);
|
||||
border-bottom: var(--border-card);
|
||||
border-bottom: 2px solid var(--color-neutral-200);
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.kx-list-table tbody td {
|
||||
padding: 0 var(--space-3);
|
||||
height: 44px;
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
color: var(--color-neutral-700);
|
||||
vertical-align: middle;
|
||||
}
|
||||
@ -220,6 +220,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
max-width: 640px; /* 입력 폼이 콘텐츠 전폭으로 늘어나지 않게 제한(WISE 폼 폭) */
|
||||
}
|
||||
.kx-formgrid .kx-field--full {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
@ -82,6 +82,9 @@ interface MdiState {
|
||||
* @returns 닫은 뒤 이동해야 할 활성 경로(없으면 null) — 호출부가 navigate 담당.
|
||||
*/
|
||||
closeTab: (path: string) => string | null;
|
||||
|
||||
/** 전 탭 닫기(모두 닫기). @returns 이동할 기본 경로('/home') — 호출부가 navigate 담당. */
|
||||
closeAll: () => string;
|
||||
}
|
||||
|
||||
const initial = readStored();
|
||||
@ -137,4 +140,11 @@ export const useMdiStore = create<MdiState>((set, get) => ({
|
||||
set(state);
|
||||
return nextActive;
|
||||
},
|
||||
|
||||
closeAll: () => {
|
||||
const state = { tabs: [], activePath: null };
|
||||
persist(state);
|
||||
set(state);
|
||||
return '/home';
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user