From 433eb83a1673c267a41679c7912681ac05ce211b Mon Sep 17 00:00:00 2001 From: zio Date: Sun, 12 Jul 2026 17:44:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(ux,domain,data):=20checkpoint=20=E2=80=94?= =?UTF-8?q?=20search=20domain,=20approval=20WISE,=20calendar=20M/W/D,=20ho?= =?UTF-8?q?lidays=202050,=20dept=20org-tree,=20offcanvas,=20settings=20ico?= =?UTF-8?q?n,=20V39=20demo=20realism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/DESIGN_SYSTEM_NIFTY.md | 96 +++ docs/PLANNING.md | 108 ++- docs/analysis/nifty-design-refs.md | 30 + .../system/holiday/HolidayController.java | 87 +++ .../system/holiday/HolidayCreateRequest.java | 16 + .../kintex/system/holiday/HolidayDto.java | 18 + .../kintex/system/holiday/HolidayMapper.java | 60 ++ .../kintex/system/holiday/HolidayService.java | 123 ++++ .../system/holiday/HolidayToggleRequest.java | 8 + .../kintex/work/search/SearchMapper.java | 30 +- .../kintex/work/search/SearchResultItem.java | 8 +- .../kintex/work/search/SearchService.java | 28 +- .../db/migration/V39__demo_realism.sql | 203 ++++++ .../db/migration/V41__holidays_2050.sql | 472 ++++++++++++ src/frontend/src/App.tsx | 2 + .../src/components/layout/AppShell.tsx | 11 + .../src/components/layout/MdiTabBar.tsx | 16 + .../src/components/layout/app-shell.css | 23 + src/frontend/src/components/ui/Offcanvas.tsx | 154 ++++ src/frontend/src/components/ui/offcanvas.css | 122 ++++ src/frontend/src/i18n/locales/en.json | 148 +++- src/frontend/src/i18n/locales/ja.json | 148 +++- src/frontend/src/i18n/locales/ko.json | 148 +++- src/frontend/src/i18n/locales/zh.json | 148 +++- .../src/screens/admin/DeptAdminPage.tsx | 381 ++++++++-- .../src/screens/admin/HolidayAdminPage.tsx | 271 +++++++ .../src/screens/admin/admin-modules.css | 55 +- src/frontend/src/screens/admin/dept-admin.css | 249 +++++++ src/frontend/src/screens/admin/holidayApi.ts | 48 ++ .../src/screens/analytics/AiConsolePanel.tsx | 5 +- .../screens/approval/ApprovalComposePanel.tsx | 283 ++++++++ .../screens/approval/ApprovalLineEditor.tsx | 104 +++ .../approval/ManagerApprovalQueuePage.tsx | 183 +++-- .../screens/approval/ReviewDetailsPage.tsx | 132 +++- .../src/screens/approval/approval-form.css | 346 +++++++++ .../src/screens/approval/approval-queue.css | 59 +- src/frontend/src/screens/auction/auction.css | 18 +- src/frontend/src/screens/cms/cms.css | 10 +- .../src/screens/dashboard/dashboard.css | 20 +- .../screens/hallassign/HallAssignmentPage.tsx | 176 ++++- .../src/screens/hallassign/hallassign.css | 114 +++ src/frontend/src/screens/home/HomePage.tsx | 5 +- src/frontend/src/screens/home/home.css | 9 +- .../src/screens/public/AgencyTrackPage.tsx | 166 +++++ .../src/screens/public/BusinessTrackPage.tsx | 184 +++++ .../src/screens/public/PublicGatewayPage.tsx | 111 +++ .../src/screens/public/PublicShell.tsx | 93 ++- .../src/screens/public/VisitorTrackPage.tsx | 180 +++++ src/frontend/src/screens/public/index.ts | 6 + src/frontend/src/screens/public/public.css | 425 ++++++++++- src/frontend/src/screens/public/publicApi.ts | 13 + .../src/screens/public/trackShared.tsx | 137 ++++ .../schedule/ExhibitionSchedulePage.tsx | 115 +-- .../src/screens/schedule/KxCalendarView.tsx | 685 ++++++++++++++++++ .../src/screens/schedule/calendarView.css | 541 ++++++++++++++ .../src/screens/schedule/schedule.css | 14 +- src/frontend/src/screens/shared.css | 46 +- src/frontend/src/screens/work/SearchPage.tsx | 52 +- .../src/screens/work/WorkSchedulePage.tsx | 160 ++-- src/frontend/src/screens/work/work.css | 17 +- src/frontend/src/store/mdiStore.ts | 10 + 61 files changed, 7227 insertions(+), 403 deletions(-) create mode 100644 docs/DESIGN_SYSTEM_NIFTY.md create mode 100644 docs/analysis/nifty-design-refs.md create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayCreateRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayMapper.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayToggleRequest.java create mode 100644 src/backend/src/main/resources/db/migration/V39__demo_realism.sql create mode 100644 src/backend/src/main/resources/db/migration/V41__holidays_2050.sql create mode 100644 src/frontend/src/components/ui/Offcanvas.tsx create mode 100644 src/frontend/src/components/ui/offcanvas.css create mode 100644 src/frontend/src/screens/admin/HolidayAdminPage.tsx create mode 100644 src/frontend/src/screens/admin/dept-admin.css create mode 100644 src/frontend/src/screens/admin/holidayApi.ts create mode 100644 src/frontend/src/screens/approval/ApprovalComposePanel.tsx create mode 100644 src/frontend/src/screens/approval/ApprovalLineEditor.tsx create mode 100644 src/frontend/src/screens/approval/approval-form.css create mode 100644 src/frontend/src/screens/public/AgencyTrackPage.tsx create mode 100644 src/frontend/src/screens/public/BusinessTrackPage.tsx create mode 100644 src/frontend/src/screens/public/PublicGatewayPage.tsx create mode 100644 src/frontend/src/screens/public/VisitorTrackPage.tsx create mode 100644 src/frontend/src/screens/public/trackShared.tsx create mode 100644 src/frontend/src/screens/schedule/KxCalendarView.tsx create mode 100644 src/frontend/src/screens/schedule/calendarView.css diff --git a/docs/DESIGN_SYSTEM_NIFTY.md b/docs/DESIGN_SYSTEM_NIFTY.md new file mode 100644 index 0000000..4b82a25 --- /dev/null +++ b/docs/DESIGN_SYSTEM_NIFTY.md @@ -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 스타일 학습" 지시 | diff --git a/docs/PLANNING.md b/docs/PLANNING.md index aa24409..90d46bb 100644 --- a/docs/PLANNING.md +++ b/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 후속 반영 필요), 시크릿 미기재 | diff --git a/docs/analysis/nifty-design-refs.md b/docs/analysis/nifty-design-refs.md new file mode 100644 index 0000000..7a585ef --- /dev/null +++ b/docs/analysis/nifty-design-refs.md @@ -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 정합 확인 후 파일럿→확산. diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayController.java b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayController.java new file mode 100644 index 0000000..fc41548 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayController.java @@ -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) + 캘린더 공개(인증) 조회. + * + * 데이터·시드(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> 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> 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 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 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 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> 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)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayCreateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayCreateRequest.java new file mode 100644 index 0000000..ede8d0e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayCreateRequest.java @@ -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) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayDto.java new file mode 100644 index 0000000..2a15411 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayDto.java @@ -0,0 +1,18 @@ +package com.zioinfo.kintex.system.holiday; + +/** + * 공휴일 DTO — 관리 화면·캘린더 붉은색 표시 공용(민감정보 없음). + *
    + *
  • {@code ymd} — 날짜(yyyy-MM-dd).
  • + *
  • {@code name} — 공휴일명(예: 신정, 설날, 대체공휴일(추석)).
  • + *
  • {@code isSubstitute} — 대체공휴일 여부(배지 표시용).
  • + *
  • {@code isHoliday} — 휴무 여부(임시휴무 토글, 관리 화면 전용). 공개 조회는 true만 반환.
  • + *
+ * 데이터·시드는 V41__holidays_2050.sql(2024~2050) 소유. 본 DTO는 조회·관리 표현만. + */ +public record HolidayDto( + String ymd, + String name, + boolean isSubstitute, + boolean isHoliday) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayMapper.java new file mode 100644 index 0000000..7671ff8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/holiday/HolidayMapper.java @@ -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 """) diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java index 05c9634..1947828 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java @@ -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) { } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java index 00bd187..50edeec 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java @@ -26,8 +26,32 @@ public class SearchService { int size = limit <= 0 ? 30 : Math.min(limit, 100); boolean seeAll = scope.canSeeAll(principal); List> 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); } diff --git a/src/backend/src/main/resources/db/migration/V39__demo_realism.sql b/src/backend/src/main/resources/db/migration/V39__demo_realism.sql new file mode 100644 index 0000000..947523d --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V39__demo_realism.sql @@ -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!Demo1) +-- 이며 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 존재 +-- ============================================================================ diff --git a/src/backend/src/main/resources/db/migration/V41__holidays_2050.sql b/src/backend/src/main/resources/db/migration/V41__holidays_2050.sql new file mode 100644 index 0000000..84fc525 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V41__holidays_2050.sql @@ -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; diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 5fdc26a..fef6238 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */} diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 7a4749c..88a14be 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -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')} > + diff --git a/src/frontend/src/components/layout/MdiTabBar.tsx b/src/frontend/src/components/layout/MdiTabBar.tsx index 01b1b74..0dc57d8 100644 --- a/src/frontend/src/components/layout/MdiTabBar.tsx +++ b/src/frontend/src/components/layout/MdiTabBar.tsx @@ -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() { ); })} + {tabs.length > 1 && ( + + )} ); } diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index a4d87c4..ec9c001 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -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); +} diff --git a/src/frontend/src/components/ui/Offcanvas.tsx b/src/frontend/src/components/ui/Offcanvas.tsx new file mode 100644 index 0000000..3dcfa50 --- /dev/null +++ b/src/frontend/src/components/ui/Offcanvas.tsx @@ -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 변수 상속) + * + * 사용: + * setOpen(false)} title="배정 상세"> + * …상세 내용… + * + */ +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(null); + const prevFocusRef = useRef(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) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== 'Tab') return; + const panel = panelRef.current; + if (!panel) return; + const focusables = panel.querySelectorAll( + '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 ( +
+ + ); +} diff --git a/src/frontend/src/components/ui/offcanvas.css b/src/frontend/src/components/ui/offcanvas.css new file mode 100644 index 0000000..929b0a8 --- /dev/null +++ b/src/frontend/src/components/ui/offcanvas.css @@ -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; + } +} diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index 63158ed..a273e4b 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index e1ae6af..c72fce2 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -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": "保存" } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index 8bad820..ef66105 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -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": "저장" } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index aff6832..3aabea7 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -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": "保存" } -} +} \ No newline at end of file diff --git a/src/frontend/src/screens/admin/DeptAdminPage.tsx b/src/frontend/src/screens/admin/DeptAdminPage.tsx index 00a3c41..56881c8 100644 --- a/src/frontend/src/screens/admin/DeptAdminPage.tsx +++ b/src/frontend/src/screens/admin/DeptAdminPage.tsx @@ -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 { + const map = new Map(); + 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, 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; ancestors: Set } { + const matched = new Set(); + const ancestors = new Set(); + 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 { + const parts: string[] = []; + let cur: DeptNode | undefined = index.get(id); + const guard = new Set(); + 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 { + const set = new Set(); + 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)} + {text.slice(idx, idx + term.length)} + {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>(new Set()); + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(''); + const [debouncedQ, setDebouncedQ] = useState(''); + const [modal, setModal] = useState(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 (
@@ -49,79 +189,206 @@ export function DeptAdminPage() {

{t('dept.title')}

{t('dept.subtitle')}

- +
+ + + +
- {q.isLoading && } + {q.isLoading && } {q.isError && !q.isLoading && q.refetch()} />} {!q.isLoading && !q.isError && ( -
+ <> +
+ + + setQuery(e.target.value)} + aria-label={t('dept.searchPlaceholder')} + /> + +
+ {flat.length === 0 ? ( - +
+ +
) : ( -
    - {flat.map(({ node, depth }) => ( -
  • - - {node.deptName} - {node.useYn === 'N' && · {t('sysadmin.noUse')}} - - - - - - -
  • - ))} -
+
+ {/* 좌 — 부서 트리 */} + + + {/* 우 — 선택 부서 상세/편집 */} +
+ {!selectedNode ? ( +
+ +

{t('dept.selectHint')}

+
+ ) : ( + <> +
+
+

{selectedNode.deptName}

+

{deptPath(selectedNode.id, index)}

+
+
+ + + +
+
+
+
+
{t('dept.name')}
{selectedNode.deptName}
+
+
+
{t('dept.colId')}
{selectedNode.id}
+
+
+
{t('dept.colParent')}
+
{selectedNode.parentId + ? `${index.get(selectedNode.parentId)?.deptName ?? selectedNode.parentId}` + : t('dept.topLevel')}
+
+
+
{t('sysadmin.sortOrder')}
#{selectedNode.sortOrder ?? 0}
+
+
+
{t('sysadmin.useYn')}
+
{selectedNode.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')}
+
+
+
{t('dept.colChildCount')}
+
{t('dept.childCountValue', { n: selectedNode.children?.length ?? 0 })}
+
+
+ + )} +
+
)} -
+ )} - {editing !== undefined && ( - setEditing(undefined)} onSubmit={(b) => saveM.mutate(b)} /> + {modal && ( + setModal(null)} + onSubmit={(b) => saveM.mutate(b)} + /> )} {toast}
); } -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; + 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(isEdit ? node!.parentId : state.parentId); + const [sortOrder, setSort] = useState(node?.sortOrder ?? nextSort); const [useYn, setUseYn] = useState(node?.useYn ?? 'Y'); const [error, setError] = useState(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 (
-

{isNew ? t('dept.add') : t('dept.edit')}

+

{isEdit ? t('dept.edit') : (state.parentId ? t('dept.addChild') : t('dept.add'))}

+