Compare commits

26 Commits

Author SHA1 Message Date
DDD1542 318cac4f68 Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m44s
2026-05-19 21:31:11 +09:00
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00
johngreen 58ede650ae fix(테이블타입): ⋯ 드롭다운 row click 누수 차단 (#23)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m30s
johngreen → main: Radix DropdownMenuTrigger onPointerDown 으로 인한 row click 누수 차단
2026-05-19 09:45:37 +00:00
johngreen 4c5b672f40 fix(테이블타입): ⋯ 드롭다운 클릭이 row 로 새서 상세 패널이 열리던 문제
Radix DropdownMenuTrigger 는 onPointerDown 으로 트리거되는데 기존엔 onClick
stopPropagation 만 있어서, 부모 row 의 onClick(=setSelectedColumn)이 같이
발화 → 상세 패널이 슬라이드 in → 중앙 ColumnGrid 가 오버레이에 가려져
squish 처럼 보이던 문제.

⋯ 버튼을 감싸는 div 에 onClick / onPointerDown / onMouseDown 세 가지에
모두 stopPropagation 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:44:55 +09:00
johngreen 904fdd33e7 feat(테이블타입): 컬럼 단건 DROP + CreateTableModal flex 레이아웃 수정 (#22)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m20s
johngreen → main: 컬럼 단건 DROP 기능 + CreateTableModal 다이얼로그 flex 레이아웃 수정
2026-05-19 06:02:51 +00:00
johngreen f73e468f66 feat(테이블타입): 컬럼 단건 DROP 기능 — ColumnGrid ⋯ 메뉴에 "컬럼 삭제" 추가
- DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일)
- 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호
- 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록
- DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용)
- ddlApi.dropColumn 헬퍼
- ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템
- page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:49:07 +09:00
johngreen b25a6324f8 fix(테이블타입): CreateTableModal 다이얼로그 flex 레이아웃 — 스크롤 가능한 본문 + 고정 푸터
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:48:47 +09:00
hjjeong fc615a70be Merge pull request 'fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈' (#21) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 15m1s
Reviewed-on: #21
2026-05-19 02:25:10 +00:00
hjjeong 947b31eff5 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-19 11:10:04 +09:00
hjjeong 46707bd116 feat(admin): 외부 DB 커넥션 멀티 DB 테스트 + 프로비저닝 시퀀스 reset 보강
기존엔 PostgreSQL 만 테스트 가능했고, V001 (SERIAL→VARCHAR) 마이그레이션 이후 회사 프로비저닝 시 시퀀스가 max(id) 보다 작은 상태로 남아있어서 새 외부 커넥션 등록 시 duplicate key 가 재발하던 문제 해결.

backend
- build.gradle: MariaDB/MySQL/MSSQL/SQLite JDBC 드라이버 4종 runtimeOnly 추가
- ExternalDbConnectionService.executeConnectionTest: PostgreSQL-only 가드 제거, dbType 별 JDBC URL/props 분기 구현 (postgresql/mysql/mariadb/mssql/sqlite). defaultPort helper 추가
- mapper/externalDbConnection.xml: INSERT/UPDATE 의 port/connection_timeout/query_timeout/max_connections 에 ::varchar 캐스팅 추가 (V001 으로 VARCHAR 가 됐는데 클라가 숫자로 보내서 character varying = bigint 비교 불가로 500 나던 것)
- DataCopier.resetSequences: VARCHAR PK + 시퀀스 의존성이 남은 컬럼도 setval 대상에 포함. MAX(col::bigint) + col ~ '^[0-9]+$' 정규식으로 type mismatch 회피하면서 숫자형 VARCHAR PK 만 안전하게 reset

frontend
- ExternalDbConnectionModal: DialogContent 를 flex 컬럼으로, 본문에 자체 스크롤 + Footer shrink-0 → 길어진 폼에서도 취소/생성 버튼이 항상 보이도록

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:01:14 +09:00
DDD1542 467a41a3a8 Merge origin/main into gbpark-node — johngreen 11 commits 받음 2026-05-18 11:47:41 +09:00
johngreen 75f6883497 fix(테이블타입): 헤더 인라인 편집을 명시적 연필 아이콘 진입으로 + 컬럼명 폰트 축소
Build & Deploy to K8s / build-and-deploy (push) Successful in 16m6s
1) 테이블 헤더 (표시명/설명) 편집 진입 방식 변경
   - 기존: 텍스트 div 자체가 role="button" + onClick 이라 무심코 클릭 시 input 으로 전환
   - 변경: 텍스트는 단순 span, 옆에 작은 Pencil 아이콘 버튼 추가. 그 버튼 클릭해야 편집 모드 진입.
   - 연필 아이콘은 평소 muted-foreground/50 톤, hover 시 진해짐 (group-hover 의존 X — Tailwind variant 캐시 회피).
   - 편집 모드 동작 (Enter / Esc / blur 커밋) 은 그대로.

2) ColumnGrid: 컬럼 라벨 text-sm → text-xs (14px → 12px)
   - 가운데 본문 컬럼 행이 너무 커보이던 문제. 좌측 list 폰트(이전 commit) 와 비례 맞춤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:06:07 +09:00
johngreen d306ac2865 fix(테이블타입): dropdown key 중복 + hook 순서 + 탭바 outline + 좌측 list 폰트 사이즈
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m43s
오늘 시리즈 후속 UX 다듬기 + 회귀 fix:

1) ColumnDetailPanel: dropdown key 중복 방어
   - codeInfoOptions 에 placeholder "none" + 데이터 "none" 중복 시 React 가 'two children
     with the same key, none' 으로 거부 → filter 로 사전 제거.
   - refTableOpts 도 referenceTableOptions/tables 어디서든 중복 들어오면 같은 증상 →
     Set 기반 dedupe.

2) ColumnDetailPanel: hook 순서 위반 수정
   - 기존 'if (!column) return null' 이 useMemo(refTableOpts) 앞에 있어서
     column null/존재 케이스마다 hook 호출 수가 달라짐 (Rules of Hooks 위반).
     overlay 패턴 도입 후 column null 케이스가 자주 들어오면서 드러남.
   - early return 을 모든 hook 뒤로 이동.

3) v5-layout.css 탭바: Chrome 식 outline 스타일
   - 비활성 탭도 각자 outline 보이게 (border:1px solid var(--v5-border))로 카드처럼 분리.
   - 활성 탭은 border + surface-hover 배경 + 위쪽 primary 1px inset 강조선.
   - 위 모서리 rounded, margin-bottom:-1px 로 탭바 하단 border 와 seamless 연결.

4) 좌측 테이블 list 폰트 사이즈 축소
   - 한글명 16px → 13px, 영문명 12px → 10.5px, 행 padding 7px → 6px.
   - 280px 좁은 패널에 맞는 컴팩트 비율로 v5 컨벤션 정렬.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:42:45 +09:00
johngreen 78c5e3e358 fix(테이블타입): 좌측 일괄선택 영역 layout shift + 우측 디테일 패널 overlay/slide-in 으로 전환
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m53s
기존 UX 이슈 3가지:
- 좌측 테이블 목록에서 체크박스 첫 선택 시 삭제 버튼이 등장하면서
  헤더 영역 높이가 변해 아래 리스트가 살짝 밀림 (layout shift).
- 우측 디테일 패널이 conditional render 라 컬럼 클릭 시 가운데 본문이
  380px 만큼 좁아져 컬럼명/라벨이 truncate 되며 "찌그러진" 느낌.
- 닫는 방법이 X 버튼뿐이라 토글 직관성 부족.

변경:
- 좌측 헤더 영역에 min-h-9 고정 — 삭제 버튼 등장해도 높이 고정, 리스트 안 흔들림.
- 우측 디테일 패널을 overlay 로 전환: absolute right-0 z-20 + shadow-2xl.
  transition-transform + translate-x-{0|full} 로 300ms ease-out slide-in/out.
  pointer-events-none 으로 닫혀있을 때 클릭 차단.
- 가운데 본문 width 변동 0 — 컬럼 클릭해도 안 좁아짐.
- 컬럼 토글: 같은 컬럼 재클릭 시 디테일 패널 닫힘. X 버튼/외부 트리거도 그대로 동작.

invyone admin 다른 화면들과의 일관성보다 가운데 본문 공간 보존이 우선이라
overlay 패턴 채택. 다른 화면(screenMngList, deptMngList)은 detail 영역이
처음부터 펼쳐진 2-pane 구조라 별개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:59:53 +09:00
johngreen 6b204806b6 fix(공통코드): 그룹 코드 중복 INSERT 시 친절한 400 메시지 (500 → IllegalArgumentException)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m17s
기존: 중복 그룹 코드 등록 시 PK violation 으로 500 + "서버 내부 오류"
→ 사용자가 왜 안 되는지 알 수 없음.

변경: insertCodeInfo 진입 시 getCodeInfoInfo 로 사전 체크. 이미 존재하면
IllegalArgumentException 으로 던져 GlobalExceptionHandler 가 자동으로
400 + "이미 존재하는 그룹 코드입니다: {코드}" 메시지로 응답.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:34:41 +09:00
johngreen d8877b243a chore(테이블타입): legacy input_type 1,207 row 표준 8종으로 통합 (V026 / RUN_091)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m33s
5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 빠뜨린
운영 DB 데이터 정리. 90787d83 의 화이트리스트 확장 fix 는 회복용
보호막이었고, 본 PR 은 데이터를 표준으로 통합하는 후속 정리.

매핑:
  category/select/radio/checkbox/boolean → code
  textarea → text
  datetime → date

영향: 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 0 row.
WHERE input_type IN (...) 으로 멱등 (재실행 시 0 row).

화이트리스트 축소는 운영 안정 확인 후 별도 PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:30:39 +09:00
johngreen 90787d837f fix(테이블타입): USER_SELECTABLE_INPUT_TYPES 화이트리스트에 legacy input_type 7개 복원
Build & Deploy to K8s / build-and-deploy (push) Successful in 15m1s
5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서 운영 DB 의
옛 input_type 값들을 매핑/정리하는 마이그레이션을 빠뜨려, 컬럼 설정
저장 batch POST 가 한 row 라도 legacy 값(category/select/textarea/
checkbox/radio/datetime/boolean)을 포함하면 400 거부.

운영 메타 DB 실측: 화이트리스트 밖 row 1,207건 (category 886,
select 149, textarea 102, checkbox 55, radio 12, datetime 2, boolean 1).

운영 데이터/UI 의미를 보존하기 위해 매핑이 아닌 화이트리스트 확장
(legacy 7종 추가)으로 회복. legacy 정리는 별도 PR 에서 점진적으로.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:29:13 +09:00
johngreen 752e4fb644 fix(테이블타입): syncScreenLayoutsInputType SQL — SCREEN_LAYOUTS.PROPERTIES 가 varchar 라 JSONB 캐스팅 추가
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m19s
운영 DB 의 SCREEN_LAYOUTS.PROPERTIES 컬럼이 character varying 인데 mapper SQL 은
PROPERTIES->>'...' 와 JSONB_SET(PROPERTIES, ...) 를 그대로 사용해
PG 가 'operator does not exist: character varying ->> unknown' 으로 거부.
이로 인해 syncScreenLayouts 가 던지는 SQLException 이 try-catch 로
무시되긴 하지만 외부 @Transactional 이 이미 aborted 상태가 되어
후속 ensureTableInLabels (insertTableLabelIfNotExists) 가
'current transaction is aborted' 로 연쇄 실패 → 컬럼 설정 저장 500.

- SL.PROPERTIES::JSONB 캐스팅 (WHERE / SET 양쪽)
- JSONB_SET 결과를 ::TEXT 로 캐스팅해 varchar 컬럼에 안전 저장
- 운영 4 DB (invyone, siflex/test01/test02 _invyone) 전수 검증:
  invalid JSON row 0건 → 캐스팅 안전

mapper SQL 만 변경. DB 마이그레이션 불필요.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:55:48 +09:00
johngreen 14832a28ab fix(테이블타입): TABLE_TYPE_COLUMNS 에 ON CONFLICT 매칭용 UNIQUE INDEX 추가 + 중복 정리
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m27s
테이블 타입관리의 모든 쓰기 API (UNIQUE/NOT NULL 토글, 컬럼 설정 저장,
input-type upsert) 가 500 반환. 원인은 mapper SQL 의
ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 가 매칭할 unique
제약/인덱스가 운영 DB 에 존재하지 않아 PG 가
"there is no unique or exclusion constraint matching the ON CONFLICT
specification" 으로 거부.

- StartupSchemaMigrator MIGRATIONS 에 V025 / RUN_090 (1) (2) 추가:
  (1) ROW_NUMBER 로 (table, column, company) 중복 행 정리
      (운영 메타 DB 실측 2 그룹 / 4 row — 동일 데이터의 NULL updated_date
      옛 row 제거. 테넌트 DB 들은 중복 0건).
  (2) UX_TABLE_TYPE_COLUMNS_TCC UNIQUE INDEX 생성 (IF NOT EXISTS — 멱등).
- RUN_090_MIGRATION.md 신설.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:39:30 +09:00
johngreen a0a4dc3bf5 fix(테이블타입): TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO DB rename 누락 보완
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m24s
5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 6 군데 컬럼
참조를 CODE_INFO 로 바꾸면서 DB 컬럼 rename 마이그레이션을 빠뜨려,
모든 테넌트 사이트에서 테이블타입관리 > 테이블 클릭 시
GET /api/table-management/tables/{name}/columns 가 500
(column "code_info" does not exist) 을 반환.

- StartupSchemaMigrator MIGRATIONS 에 V024 항목 추가
  DO 블록으로 information_schema 확인 후 rename — 멱등.
- RUN_089_MIGRATION.md 신설 (V023 IS_SOLUTION_ONLY 운영 가이드도 합본).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:38 +09:00
johngreen 8fff53b165 Merge pull request 'fix(멀티테넌시): 테넌트 사이트 관리자 메뉴에서 솔루션 전용 메뉴 차단' (#21) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m3s
2026-05-15 18:36:42 +09:00
johngreen c530a67cee fix(멀티테넌시): 테넌트 사이트 관리자 메뉴에서 솔루션 전용 메뉴 차단
- AdminController.getAdminMenus 에 Host 헤더 기반 is_management_host 추가
  (기존엔 user-menus 만 필터, /admin/menus 는 미적용이라 관리자 모드 사이드바에서 노출됨)
- admin.xml selectAdminMenuList anchor + recursive 양쪽에 IS_SOLUTION_ONLY 필터 추가
- StartupSchemaMigrator: ALTER 외에 UPDATE 추가, 프로비저닝된 테넌트 DB 의
  회사관리/서브도메인관리/감사로그 메뉴 행을 부팅 시 IS_SOLUTION_ONLY=TRUE 로 마킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:35:33 +09:00
DDD1542 34060d9534 Merge branch 'gbpark-node'
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m32s
2026-05-15 16:55:31 +09:00
hjjeong d61777ab5f fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈
- mapper/externalDbConnection.xml: WHERE ID = #{id} 5곳 + ID != #{exclude_id} 1곳에 ::varchar 캐스팅 추가
  (EXTERNAL_DB_CONNECTIONS.ID 가 V001 마이그레이션으로 VARCHAR 인데 long 바인딩되어 character varying = bigint 비교 불가로 500 발생하던 것을 해결)
- exconList: 페이지 overflow-hidden + Tabs/TabsContent 가 flex 컨테이너, ResponsiveDataView scrollContainer 활성화로 테이블 안에서만 sticky header + 자체 스크롤
- exconList/RestApiConnectionList: text-3xl→text-lg/text-sm→text-xs/h-10→h-8 등 컴팩트 폰트로 통일 (배치관리/플로우관리와 톤 매칭)
- RestApiConnectionList: Table divClassName 으로 wrapper 자체에 스크롤 위임 + sticky TableHeader 적용
- ResponsiveDataView: compact 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소, scrollContainer 모드에서 @3xl:block 이 flex 를 덮어쓰던 우선순위 충돌 해결, sticky header 알파 제거
- batchmngList: Pagination 컴포넌트 적용 (RPS batchmngList 참고, 페이지당 10/20/50/100 선택), 컨테이너를 h-full min-h-0 overflow-hidden + 리스트만 자체 스크롤로 변경

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:38:23 +09:00
johngreen d5f9814865 Merge remote-tracking branch 'origin/main' into johngreen
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m23s
2026-05-15 12:22:45 +09:00
johngreen 824a3100ce security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:59:15 +09:00
182 changed files with 16026 additions and 14367 deletions
+5
View File
@@ -33,6 +33,11 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.postgresql:postgresql' implementation 'org.postgresql:postgresql'
// 외부 커넥션 테스트용 JDBC 드라이버 (runtimeOnly — 내부 비즈니스 DB 는 PostgreSQL 만 사용)
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-core'
@@ -5,9 +5,15 @@ import java.util.Set;
public final class InputTypeConstants { public final class InputTypeConstants {
private InputTypeConstants() {} private InputTypeConstants() {}
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */ /**
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
*/
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of( public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity", "text", "number", "date", "code", "entity",
"numbering", "file", "image" "numbering", "file", "image",
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
); );
} }
@@ -1,7 +1,9 @@
package com.erp.controller; package com.erp.controller;
import com.erp.dto.ApiResponse; import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.AdminService; import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -30,13 +32,17 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode, @RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role, @RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId, @RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) { @RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode); params.put("company_code", companyCode);
params.put("user_type", role); params.put("user_type", role);
params.put("user_id", userId); params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko"); params.putIfAbsent("user_lang", "ko");
params.put("is_management_screen", params.put("is_management_screen",
params.get("menu_type") == null || "true".equals(params.get("include_inactive"))); params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공")); return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
} }
@@ -49,11 +55,15 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode, @RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role, @RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId, @RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) { @RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode); params.put("company_code", companyCode);
params.put("user_type", role); params.put("user_type", role);
params.put("user_id", userId); params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko"); params.putIfAbsent("user_lang", "ko");
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공")); return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
} }
@@ -1,7 +1,9 @@
package com.erp.controller; package com.erp.controller;
import com.erp.dto.ApiResponse; import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.CompanyManagementService; import com.erp.service.CompanyManagementService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -16,6 +18,7 @@ import java.util.Map;
@Slf4j @Slf4j
public class CompanyManagementController { public class CompanyManagementController {
private final SuperAdminGuard guard;
private final CompanyManagementService companyManagementService; private final CompanyManagementService companyManagementService;
/** /**
@@ -24,9 +27,12 @@ public class CompanyManagementController {
*/ */
@DeleteMapping("/{companyCode}") @DeleteMapping("/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany( public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
HttpServletRequest request,
@PathVariable String companyCode, @PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) { @RequestBody(required = false) Map<String, Object> body) {
guard.enforce(request);
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode); params.put("company_code", companyCode);
if (body != null) { if (body != null) {
@@ -52,7 +58,11 @@ public class CompanyManagementController {
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음) * ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
*/ */
@GetMapping("/disk-usage/all") @GetMapping("/disk-usage/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() { public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
HttpServletRequest request) {
guard.enforce(request);
try { try {
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage(); Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
return ResponseEntity.ok(ApiResponse.success(data)); return ResponseEntity.ok(ApiResponse.success(data));
@@ -68,7 +78,11 @@ public class CompanyManagementController {
*/ */
@GetMapping("/{companyCode}/disk-usage") @GetMapping("/{companyCode}/disk-usage")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage( public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
HttpServletRequest request,
@PathVariable String companyCode) { @PathVariable String companyCode) {
guard.enforce(request);
try { try {
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode); Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
return ResponseEntity.ok(ApiResponse.success(data)); return ResponseEntity.ok(ApiResponse.success(data));
@@ -91,6 +91,32 @@ public class DdlController {
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message"))); return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
} }
/**
* DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제
*/
@DeleteMapping("/tables/{tableName}/columns/{columnName}")
public ResponseEntity<ApiResponse<?>> dropColumn(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
if (Boolean.TRUE.equals(result.get("success"))) {
return ResponseEntity.ok(ApiResponse.success(Map.of(
"table_name", result.get("table_name"),
"column_name", result.get("column_name"),
"executed_query", result.get("executed_query")
), (String) result.get("message")));
}
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
}
/** /**
* DELETE /api/ddl/tables/{tableName} - 테이블 삭제 * DELETE /api/ddl/tables/{tableName} - 테이블 삭제
*/ */
@@ -142,6 +142,135 @@ public class DepartmentController {
} }
} }
/**
* 일괄 미리보기 (read-only validation).
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
*/
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
}
String action = body.get("action") != null ? body.get("action").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null) {
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
}
try {
List<Map<String, Object>> result;
switch (action) {
case "create":
result = departmentService.bulkPreviewCreate(companyCode, rows);
break;
case "update_department":
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
break;
case "update_manager":
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
break;
default:
return ResponseEntity.status(400)
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
}
int ok = 0, err = 0;
for (Map<String, Object> r : result) {
if ("ok".equals(r.get("result"))) ok++; else err++;
}
Map<String, Object> data = new java.util.HashMap<>();
data.put("rows", result);
data.put("ok_count", ok);
data.put("error_count", err);
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄등록 적용 (@Transactional, all-or-nothing).
* POST /api/departments/companies/{companyCode}/departments/bulk/create
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/create")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
}
try {
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("inserted", inserted);
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
* POST /api/departments/companies/{companyCode}/departments/bulk/update
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/update")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
}
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
}
try {
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("updated", updated);
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** /**
* 부서 삭제 (soft-delete, V1 slim scope). * 부서 삭제 (soft-delete, V1 slim scope).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경 * - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder; import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/** /**
* Cross-tenant 어드민 엔드포인트 진입 가드. * Cross-tenant 어드민 엔드포인트 진입 가드.
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
public static boolean isMetaContext() { public static boolean isMetaContext() {
return DbContextHolder.isMeta(); return DbContextHolder.isMeta();
} }
/**
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
*/
public static void requireManagementHost(HttpServletRequest request) {
String host = request.getHeader("Host");
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Cross-tenant operations are only available on the management site");
}
}
} }
@@ -59,6 +59,12 @@ public class CrossTenantController {
*/ */
@GetMapping("/_active-companies") @GetMapping("/_active-companies")
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) { public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -92,6 +98,12 @@ public class CrossTenantController {
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers( public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request, HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) { @RequestParam Map<String, Object> queryParams) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -173,6 +185,12 @@ public class CrossTenantController {
Map<String, Object> queryParams, Map<String, Object> queryParams,
String mapperId, String mapperId,
boolean wrapSearchWithPercent) { boolean wrapSearchWithPercent) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
public ResponseEntity<Map<String, Object>> listDepartments( public ResponseEntity<Map<String, Object>> listDepartments(
HttpServletRequest request, HttpServletRequest request,
@RequestParam("company_code") String companyCode) { @RequestParam("company_code") String companyCode) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody("super_admin_required", request.getRequestURI())); .body(errorBody("super_admin_required", request.getRequestURI()));
@@ -1,6 +1,7 @@
package com.erp.crosstenant; package com.erp.crosstenant;
import com.erp.dto.ApiResponse; import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.RoleService; import com.erp.service.RoleService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
private final CrossTenantExecutor executor; private final CrossTenantExecutor executor;
private final RoleService roleService; private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ────────────────────────────────────────────── // ── 권한 그룹 CRUD ──────────────────────────────────────────────
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
if (g != null) return g; if (g != null) return g;
String targetCompany = (String) body.get("company_code"); String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try { try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> { Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body); Map<String, Object> params = new HashMap<>(body);
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
} }
return roleService.createRoleGroup(params); return roleService.createRoleGroup(params);
}); });
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
(String) body.get("auth_code"),
auditDetails(request, null));
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공")); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
if (g != null) return g; if (g != null) return g;
String targetCompany = (String) body.get("company_code"); String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try { try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> { Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body); Map<String, Object> params = new HashMap<>(body);
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
} }
return roleService.updateRoleGroup(params); return roleService.updateRoleGroup(params);
}); });
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공")); return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
ResponseEntity<ApiResponse<Void>> g = guardVoid(request); ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
if (g != null) return g; if (g != null) return g;
String actorId = (String) request.getAttribute("user_id");
try { try {
executor.runInCompany(companyCode, () -> { executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>(); Map<String, Object> p = new HashMap<>();
p.put("objid", id); p.put("objid", id);
roleService.deleteRoleGroup(p); roleService.deleteRoleGroup(p);
}); });
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공")); return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ──────── // ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) { private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
} }
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) { private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
} }
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) { private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
return null; return null;
} }
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (roleId != null) d.put("role_id", roleId);
return d;
}
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */ /** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
private String asYn(Object raw) { private String asYn(Object raw) {
if (raw == null) return null; if (raw == null) return null;
@@ -1,6 +1,7 @@
package com.erp.crosstenant; package com.erp.crosstenant;
import com.erp.dto.ApiResponse; import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.AdminService; import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
private final CrossTenantExecutor executor; private final CrossTenantExecutor executor;
private final AdminService adminService; private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ───────────────────────────────────────────────────── // ── 등록 / 수정 ─────────────────────────────────────────────────────
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
if (guard != null) return guard; if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code"); String targetCompanyCode = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try { try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode, Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body)); () -> adminService.saveUser(body));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_CREATE,
(String) body.get("user_id"),
auditDetails(request, (String) body.get("user_id")));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공")); return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request); ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard; if (guard != null) return guard;
String actorId = (String) request.getAttribute("user_id");
try { try {
executor.runInCompany(companyCode, () -> { executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId); Map<String, Object> existing = adminService.getUserInfo(userId);
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
} }
adminService.changeUserStatus(userId, "inactive"); adminService.changeUserStatus(userId, "inactive");
}); });
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_DELETE,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공")); return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage())); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
String targetCompanyCode = (String) body.get("company_code"); String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id"); String userId = (String) body.get("user_id");
String actorId = (String) request.getAttribute("user_id");
try { try {
executor.runInCompany(targetCompanyCode, () -> executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId)); adminService.resetUserPassword(userId));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_PW_RESET,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공")); return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage())); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */ /** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) { private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
/** Void 응답용 가드 (제네릭만 다름). */ /** Void 응답용 가드 (제네릭만 다름). */
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) { private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) { if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI())); .body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
} }
return null; return null;
} }
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (targetUserId != null) d.put("target_user_id", targetUserId);
return d;
}
} }
@@ -203,7 +203,110 @@ public class StartupSchemaMigrator {
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
) )
""", """,
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)" "CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
"""
UPDATE MENU_INFO
SET IS_SOLUTION_ONLY = TRUE
WHERE IS_SOLUTION_ONLY = FALSE
AND MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
)
""",
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
// - 이미 CODE_INFO 인 신규 테넌트: no-op
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_category'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_info'
) THEN
ALTER TABLE TABLE_TYPE_COLUMNS
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
END IF;
END $$
""",
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
"""
DELETE FROM TABLE_TYPE_COLUMNS
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
ORDER BY UPDATED_DATE DESC NULLS LAST,
id::bigint DESC
) AS rn
FROM TABLE_TYPE_COLUMNS
) r
WHERE r.rn > 1
)
""",
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
// IF NOT EXISTS 누락 문제도 회피.
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
// 매핑:
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
// datetime → date
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
// WHERE 절로 멱등 (재실행 시 0 row).
"""
UPDATE TABLE_TYPE_COLUMNS
SET INPUT_TYPE = CASE INPUT_TYPE
WHEN 'category' THEN 'code'
WHEN 'select' THEN 'code'
WHEN 'radio' THEN 'code'
WHEN 'checkbox' THEN 'code'
WHEN 'boolean' THEN 'code'
WHEN 'textarea' THEN 'text'
WHEN 'datetime' THEN 'date'
END,
UPDATED_DATE = NOW()
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
"""
); );
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET"; public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY"; public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
// cross-tenant write 감사 액션
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
private final SqlSession sqlSession; private final SqlSession sqlSession;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -100,13 +100,22 @@ public class DataCopier {
try (Statement us = dst.createStatement()) { try (Statement us = dst.createStatement()) {
for (String[] r : rows) { for (String[] r : rows) {
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3]; String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
if (!isIntegerLike(coltype)) { String sql;
if (isIntegerLike(coltype)) {
sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
} else if (isVarcharLike(coltype)) {
// V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고,
// INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가
// 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산.
sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))",
seq.replace("'", "''"), col, tbl, col);
} else {
skippedType++; skippedType++;
continue; continue;
} }
String sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
try { try {
us.execute(sql); us.execute(sql);
updated++; updated++;
@@ -117,14 +126,8 @@ public class DataCopier {
} }
} }
} }
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때 log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상. updated, skippedType, skippedErr, rows.size());
if (updated == 0 && skippedErr == 0) {
log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size());
} else {
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}",
updated, skippedType, skippedErr, rows.size());
}
return updated; return updated;
} }
@@ -135,6 +138,12 @@ public class DataCopier {
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2"); || t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
} }
private static boolean isVarcharLike(String coltype) {
if (coltype == null) return false;
String t = coltype.toLowerCase();
return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text");
}
private List<String> listColumns(Connection conn, String table) throws SQLException { private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>(); List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement( try (PreparedStatement ps = conn.prepareStatement(
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays; import java.util.Arrays;
@@ -40,13 +37,7 @@ public class ProvisioningController {
private final ProvisioningRegistry registry; private final ProvisioningRegistry registry;
private final SqlSession sqlSession; private final SqlSession sqlSession;
private final CompanyStatsService statsService; private final CompanyStatsService statsService;
private final SuperAdminGuard guard;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
@GetMapping("/table-groups") @GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) { public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
@@ -208,23 +199,11 @@ public class ProvisioningController {
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 권한 체크 // 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
// // CompanyMgmtController 와 동일한 가드를 공유.
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) { private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type"); guard.enforce(request);
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
"tenant.provisioning.require-super-admin=true in production)");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
} }
// --- Validation helpers --- // --- Validation helpers ---
@@ -1,5 +1,6 @@
package com.erp.provisioning; package com.erp.provisioning;
import com.erp.tenant.ReservedSubdomains;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.regex.Pattern;
/** /**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드. * `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
* *
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과 * - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단 * - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
* *
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
@Slf4j @Slf4j
public class SuperAdminGuard { public class SuperAdminGuard {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
@Value("${tenant.provisioning.require-super-admin:false}") @Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin; private boolean requireSuperAdmin;
public void enforce(HttpServletRequest request) { public void enforce(HttpServletRequest request) {
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
String host = request.getHeader("Host");
if (isTenantHost(host)) {
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
host, request.getRequestURI(), request.getAttribute("user_type"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Provisioning is only available on the management site");
}
// 2) role 체크
String userType = (String) request.getAttribute("user_type"); String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return; if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) { if (!requireSuperAdmin && userType == null) {
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
String userId = (String) request.getAttribute("user_id"); String userId = (String) request.getAttribute("user_id");
return userId == null ? "dev-anonymous" : userId; return userId == null ? "dev-anonymous" : userId;
} }
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
public String requestHost(HttpServletRequest request) {
String host = request.getHeader("Host");
return host == null ? "" : host;
}
/**
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
*/
public static boolean isTenantHost(String host) {
if (host == null || host.isBlank()) return false;
int colon = host.indexOf(':');
if (colon != -1) host = host.substring(0, colon);
host = host.toLowerCase();
if ("localhost".equals(host)) return false;
if (IPV4.matcher(host).matches()) return false;
String[] parts = host.split("\\.");
if (parts.length == 2) {
// {sub}.localhost (dev)
if (!"localhost".equals(parts[1])) return false;
String first = parts[0];
if (first.isEmpty()) return false;
return !ReservedSubdomains.VALUES.contains(first);
}
if (parts.length < 3) return false;
String first = parts[0];
return !ReservedSubdomains.VALUES.contains(first);
}
} }
@@ -57,6 +57,13 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
return; return;
} }
// 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분.
// ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요.
if (isSkippablePath(path)) {
chain.doFilter(request, response);
return;
}
String userId = (String) request.getAttribute("user_id"); String userId = (String) request.getAttribute("user_id");
String companyCode = (String) request.getAttribute("company_code"); String companyCode = (String) request.getAttribute("company_code");
@@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
chain.doFilter(request, response); chain.doFilter(request, response);
} }
private static boolean isSkippablePath(String path) {
return path.startsWith("/api/auth/")
|| path.equals("/api/admin/menus")
|| path.equals("/api/admin/user-menus")
|| path.equals("/api/admin/user-locale");
}
} }
@@ -53,6 +53,13 @@ public class CommonCodeService extends BaseService {
@Transactional @Transactional
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) { public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
Object rawCodeInfo = body.get("code_info");
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
if (codeInfo != null && !codeInfo.isEmpty()
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
}
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("code_info", body.get("code_info")); params.put("code_info", body.get("code_info"));
params.put("code_name", body.get("code_name")); params.put("code_name", body.get("code_name"));
@@ -226,6 +226,79 @@ public class DdlService extends BaseService {
} }
} }
// ─────────────────────────────────────────────────────────────────────────
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
// ─────────────────────────────────────────────────────────────────────────
public Map<String, Object> dropColumn(String tableName, String columnName,
String companyCode, String userId) {
// 1. 시스템 테이블 보호
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
}
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
}
// 3. 테이블/컬럼 존재 여부
if (!tableExists(tableName)) {
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
}
if (!columnExists(tableName, columnName)) {
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
}
// 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일)
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
try {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
// 컬럼 메타 청소
jdbcTemplate.update(
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
tableName, columnName);
jdbcTemplate.update(
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
tableName, columnName);
return null;
});
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
return Map.of(
"success", true,
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
"table_name", tableName,
"column_name", columnName,
"executed_query", ddlQuery
);
} catch (Exception e) {
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
: "";
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
"FAILED: " + rawMsg, false, errorMsg);
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
}
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// VALIDATE // VALIDATE
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -365,6 +366,330 @@ public class DepartmentService extends BaseService {
} }
} }
// ──────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 (Bulk)
// ──────────────────────────────────────────────────
private static final int BULK_MAX_ROWS = 1000;
/**
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
*/
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
Set<String> existingNames = collectActiveDeptNames(companyCode);
Set<String> batchNames = new HashSet<>();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
String dn = trimString(input.get("dept_name"));
if (dn != null) batchNames.add(dn.toLowerCase());
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄업데이트 — preview (read-only). mode = department | manager.
*/
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkUpdateRow(input, companyCode, mode);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
*/
@Transactional
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
}
int inserted = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String label = trimString(row.get("dept_name"));
try {
createDepartment(companyCode, row);
inserted++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
}
}
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
return inserted;
}
/**
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
*/
@Transactional
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
int updated = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서코드(dept_code) 필수.");
}
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
}
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
throw new IllegalArgumentException("" + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
}
try {
if ("department".equals(mode)) {
Map<String, Object> merged = buildMergedDeptBody(existing, row);
Map<String, Object> result = updateDepartment(deptCode, merged);
if (result == null) {
throw new IllegalStateException("수정 실패: " + deptCode);
}
} else {
// manager mode — row 에 명시된 role 만 sync
if (row.containsKey("approval_managers")) {
syncManagers(deptCode, companyCode, row, "approval");
}
if (row.containsKey("dept_managers")) {
syncManagers(deptCode, companyCode, row, "dept");
}
if (row.containsKey("org_leaders")) {
syncManagers(deptCode, companyCode, row, "org_leader");
}
}
updated++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + deptCode + "): " + e.getMessage());
}
}
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
return updated;
}
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
private Set<String> collectActiveDeptNames(String companyCode) {
Set<String> names = new HashSet<>();
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
Object name = d.get("dept_name");
if (name != null) names.add(name.toString().trim().toLowerCase());
}
return names;
}
/**
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
*/
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
Set<String> existingNames, Set<String> batchNames) {
String deptName = trimString(row.get("dept_name"));
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
String lower = deptName.toLowerCase();
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String mgrErr = validateManagerIds(row, companyCode);
if (mgrErr != null) return mgrErr;
return null;
}
/**
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
*/
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) return "부서코드(dept_code) 필수.";
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
return "다른 회사의 부서: " + deptCode;
}
if ("department".equals(mode)) {
String newName = trimString(row.get("dept_name"));
if (newName != null && !newName.isEmpty()) {
String existingName = existing.get("dept_name") != null
? existing.get("dept_name").toString().trim() : "";
if (!newName.equalsIgnoreCase(existingName)) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCode);
dupParams.put("dept_name", newName);
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
return "이미 존재하는 부서명: " + newName;
}
}
}
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
}
return validateManagerIds(row, companyCode);
}
private String validateParentForBulk(String parent, String companyCode) {
if (parent == null) return null;
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
Object pc = p.get("company_code");
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
}
return null;
}
private String validateDateRange(Map<String, Object> row) {
String sd = trimString(row.get("start_date"));
String ed = trimString(row.get("end_date"));
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
return null;
}
private String validateManagerIds(Map<String, Object> row, String companyCode) {
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list && list.size() > 10) {
return key + " 는 최대 10명까지 등록 가능합니다.";
}
}
List<String> ids = collectManagerUserIds(row);
if (ids.isEmpty()) return null;
Map<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", ids);
vParams.put("company_code", companyCode);
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
if (valid == null || valid.size() != ids.size()) {
Set<String> invalid = new HashSet<>(ids);
if (valid != null) invalid.removeAll(valid);
return "유효하지 않은 사용자 ID: " + invalid;
}
return null;
}
private List<String> collectManagerUserIds(Map<String, Object> row) {
List<String> ids = new ArrayList<>();
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
}
}
}
return ids;
}
/**
* 일괄업데이트 department mode — 기존값 + row override 머지.
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
*/
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
Map<String, Object> merged = new HashMap<>();
String[] textKeys = {
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
"sort_order", "status", "location"
};
for (String k : textKeys) merged.put(k, existing.get(k));
merged.put("start_date", stringifyDate(existing.get("start_date")));
merged.put("end_date", stringifyDate(existing.get("end_date")));
for (Map.Entry<String, Object> e : row.entrySet()) {
String k = e.getKey();
if ("dept_code".equals(k)) continue;
if (e.getValue() == null) continue;
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
merged.put(k, e.getValue());
}
return merged;
}
private String stringifyDate(Object date) {
if (date == null) return null;
String s = date.toString();
return s.length() >= 10 ? s.substring(0, 10) : null;
}
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// 부서원 관리 // 부서원 관리
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
private Map<String, Object> executeConnectionTest( private Map<String, Object> executeConnectionTest(
String dbType, Map<String, Object> conn, String password) { String dbType, Map<String, Object> conn, String password) {
String type = dbType == null ? "" : dbType.toLowerCase();
String host = str(conn, "host"); String host = str(conn, "host");
int port = toInt(conn, "port", 5432); int port = toInt(conn, "port", defaultPort(type));
String database = str(conn, "database_name"); String database = str(conn, "database_name");
String username = str(conn, "username"); String username = str(conn, "username");
String sslEnabled = str(conn, "ssl_enabled"); String sslEnabled = str(conn, "ssl_enabled");
int connTimeout = toInt(conn, "connection_timeout", 30); int connTimeout = toInt(conn, "connection_timeout", 30);
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
if (!"postgresql".equalsIgnoreCase(dbType)) { String url;
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("message", "이 버전에서는 PostgreSQL 연결만 테스트가 지원됩니다.");
return result;
}
String url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
Properties props = new Properties(); Properties props = new Properties();
props.setProperty("user", username); if (username != null) props.setProperty("user", username);
props.setProperty("password", password); if (password != null) props.setProperty("password", password);
props.setProperty("connect_timeout", String.valueOf(connTimeout));
props.setProperty("socket_timeout", "30"); switch (type) {
if ("Y".equalsIgnoreCase(sslEnabled)) { case "postgresql" -> {
props.setProperty("ssl", "true"); url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
props.setProperty("sslmode", "require"); props.setProperty("connect_timeout", String.valueOf(connTimeout));
props.setProperty("socket_timeout", "30");
if (ssl) {
props.setProperty("ssl", "true");
props.setProperty("sslmode", "require");
}
}
case "mysql" -> {
url = String.format("jdbc:mysql://%s:%d/%s", host, port, database);
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
props.setProperty("socketTimeout", "30000");
props.setProperty("useSSL", String.valueOf(ssl));
props.setProperty("allowPublicKeyRetrieval", "true");
}
case "mariadb" -> {
url = String.format("jdbc:mariadb://%s:%d/%s", host, port, database);
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
props.setProperty("socketTimeout", "30000");
if (ssl) props.setProperty("useSsl", "true");
}
case "mssql", "sqlserver" -> {
StringBuilder sb = new StringBuilder()
.append("jdbc:sqlserver://").append(host).append(':').append(port)
.append(";databaseName=").append(database)
.append(";loginTimeout=").append(connTimeout)
.append(";encrypt=").append(ssl ? "true;trustServerCertificate=true" : "false");
url = sb.toString();
}
case "sqlite" -> {
// SQLite: host/port 무의미. database_name 을 파일 경로로 사용 (비면 in-memory)
url = "jdbc:sqlite:" + (database != null && !database.isBlank() ? database : ":memory:");
}
default -> {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("message", "지원하지 않는 DB 타입입니다: " + dbType);
return result;
}
} }
try (Connection c = DriverManager.getConnection(url, props); try (Connection c = DriverManager.getConnection(url, props);
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true); result.put("success", true);
result.put("message", "연결 성공"); result.put("message", "연결 성공");
result.put("details", Map.of("host", host, "database", database, "port", port)); Map<String, Object> details = new LinkedHashMap<>();
details.put("host", host == null ? "" : host);
details.put("database", database == null ? "" : database);
details.put("port", port);
result.put("details", details);
return result; return result;
} catch (SQLException e) { } catch (SQLException e) {
log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage()); log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage());
@@ -342,6 +378,16 @@ public class ExternalDbConnectionService extends BaseService {
} }
} }
private int defaultPort(String dbType) {
if (dbType == null) return 5432;
return switch (dbType.toLowerCase()) {
case "mysql", "mariadb" -> 3306;
case "mssql", "sqlserver" -> 1433;
case "sqlite" -> 0;
default -> 5432;
};
}
// ── SQL 쿼리 실행 (SELECT only) ──────────────────────────────────────────── // ── SQL 쿼리 실행 (SELECT only) ────────────────────────────────────────────
public Map<String, Object> executeQuery(long id, String sql) { public Map<String, Object> executeQuery(long id, String sql) {
@@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService {
private static final String NS = "screenGroup."; private static final String NS = "screenGroup.";
/**
* canonical table / legacy table-list / hidden v2-table-list 위젯 카운트 합산.
* screen type inference 시 셋 모두 grid 화면으로 인식해야 한다 (frontend
* isTableLikeComponentType 와 동일 정책 — 2026-05-19 canonical cleanup follow-up).
*/
private static int countTableLikeWidgets(Map<String, Integer> widgetCounts) {
if (widgetCounts == null) return 0;
return widgetCounts.getOrDefault("table", 0)
+ widgetCounts.getOrDefault("table-list", 0)
+ widgetCounts.getOrDefault("v2-table-list", 0);
}
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// Screen Groups // Screen Groups
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
@@ -356,8 +368,10 @@ public class ScreenGroupService extends BaseService {
} }
// 화면 타입 추론 // 화면 타입 추론
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
// 어느 것이든 있으면 grid 로 본다.
String screenType = "form"; String screenType = "form";
if (widgetCounts.getOrDefault("table", 0) > 0) { if (countTableLikeWidgets(widgetCounts) > 0) {
screenType = "grid"; screenType = "grid";
} else if (widgetCounts.getOrDefault("custom", 0) > 2) { } else if (widgetCounts.getOrDefault("custom", 0) > 2) {
screenType = "dashboard"; screenType = "dashboard";
@@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService {
if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge); if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge);
} }
// 화면 타입 추론 // 화면 타입 추론 — canonical / legacy / hidden v2 모두 grid 로 인식
summaryMap.values().forEach(summary -> { summaryMap.values().forEach(summary -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Integer> wc = (Map<String, Integer>) summary.get("widget_counts"); Map<String, Integer> wc = (Map<String, Integer>) summary.get("widget_counts");
if (wc.getOrDefault("table-list", 0) > 0) { if (countTableLikeWidgets(wc) > 0) {
summary.put("screen_type", "grid"); summary.put("screen_type", "grid");
} else if (wc.getOrDefault("table-search-widget", 0) > 1) { } else if (wc.getOrDefault("table-search-widget", 0) > 1) {
summary.put("screen_type", "dashboard"); summary.put("screen_type", "dashboard");
@@ -0,0 +1,12 @@
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
-- 솔루션 전용 메뉴 마킹
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
WHERE MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
@@ -58,6 +58,9 @@
AND RMA.READ_YN = 'Y' AND RMA.READ_YN = 'Y'
) )
</if> </if>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL UNION ALL
@@ -105,6 +108,9 @@
AND RMA.READ_YN = 'Y' AND RMA.READ_YN = 'Y'
) )
</if> </if>
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
) )
SELECT SELECT
V.LEV V.LEV
@@ -124,26 +130,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE( , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
(SELECT MLT.LANG_TEXT , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
V.MENU_NAME_KOR
) AS TRANSLATED_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
COALESCE(V.MENU_DESC, '')
) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS) , CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@@ -152,6 +140,16 @@
FROM V_MENU V FROM V_MENU V
LEFT JOIN COMPANY_MNG CM LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE ON V.COMPANY_CODE = CM.COMPANY_CODE
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
AND MLT_NAME.LANG_CODE = #{user_lang}
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
AND MLT_DESC.LANG_CODE = #{user_lang}
ORDER BY V.PATH, V.SEQ ORDER BY V.PATH, V.SEQ
</select> </select>
@@ -187,6 +185,9 @@
AND MENU.COMPANY_CODE = #{company_code} AND MENU.COMPANY_CODE = #{company_code}
</otherwise> </otherwise>
</choose> </choose>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL UNION ALL
@@ -212,6 +213,9 @@
ON S.PARENT_OBJ_ID = V.OBJID ON S.PARENT_OBJ_ID = V.OBJID
WHERE S.OBJID != ALL(V.PATH) WHERE S.OBJID != ALL(V.PATH)
AND S.STATUS = 'active' AND S.STATUS = 'active'
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
) )
SELECT SELECT
V.LEV V.LEV
@@ -231,26 +235,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE( , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
(SELECT MLT.LANG_TEXT , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
V.MENU_NAME_KOR
) AS TRANSLATED_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
COALESCE(V.MENU_DESC, '')
) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS) , CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@@ -259,6 +245,16 @@
FROM V_MENU V FROM V_MENU V
LEFT JOIN COMPANY_MNG CM LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE ON V.COMPANY_CODE = CM.COMPANY_CODE
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
AND MLT_NAME.LANG_CODE = #{user_lang}
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
AND MLT_DESC.LANG_CODE = #{user_lang}
ORDER BY V.PATH, V.SEQ ORDER BY V.PATH, V.SEQ
</select> </select>
@@ -365,26 +361,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE( , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
(SELECT MLT.LANG_TEXT , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
V.MENU_NAME_KOR
) AS TRANSLATED_NAME
, COALESCE(
(SELECT MLT.LANG_TEXT
FROM MULTI_LANG_KEY_MASTER MLKM
JOIN MULTI_LANG_TEXT MLT
ON MLKM.KEY_ID = MLT.KEY_ID
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
AND MLT.LANG_CODE = #{user_lang}
LIMIT 1),
COALESCE(V.MENU_DESC, '')
) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS) , CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@@ -393,6 +371,16 @@
FROM V_MENU V FROM V_MENU V
LEFT JOIN COMPANY_MNG CM LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE ON V.COMPANY_CODE = CM.COMPANY_CODE
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
AND MLT_NAME.LANG_CODE = #{user_lang}
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
AND MLT_DESC.LANG_CODE = #{user_lang}
ORDER BY V.PATH, V.SEQ ORDER BY V.PATH, V.SEQ
</select> </select>
@@ -81,7 +81,7 @@
, E.CREATED_DATE , E.CREATED_DATE
, E.UPDATED_DATE , E.UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS E FROM EXTERNAL_DB_CONNECTIONS E
WHERE E.ID = #{id} WHERE E.ID = #{id}::varchar
</select> </select>
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) --> <!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
@@ -109,14 +109,14 @@
, CREATED_DATE , CREATED_DATE
, UPDATED_DATE , UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id} WHERE ID = #{id}::varchar
</select> </select>
<!-- 비밀번호만 조회 --> <!-- 비밀번호만 조회 -->
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map"> <select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
SELECT PASSWORD SELECT PASSWORD
FROM EXTERNAL_DB_CONNECTIONS FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id} WHERE ID = #{id}::varchar
</select> </select>
<!-- 이름+회사 중복 확인 --> <!-- 이름+회사 중복 확인 -->
@@ -134,7 +134,7 @@
FROM EXTERNAL_DB_CONNECTIONS FROM EXTERNAL_DB_CONNECTIONS
WHERE CONNECTION_NAME = #{connection_name} WHERE CONNECTION_NAME = #{connection_name}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND ID != #{exclude_id} AND ID != #{exclude_id}::varchar
LIMIT 1 LIMIT 1
</select> </select>
@@ -166,13 +166,13 @@
, #{description} , #{description}
, #{db_type} , #{db_type}
, #{host} , #{host}
, #{port} , #{port}::varchar
, #{database_name} , #{database_name}
, #{username} , #{username}
, #{password} , #{password}
, #{connection_timeout} , #{connection_timeout}::varchar
, #{query_timeout} , #{query_timeout}::varchar
, #{max_connections} , #{max_connections}::varchar
, #{ssl_enabled} , #{ssl_enabled}
, #{ssl_cert_path} , #{ssl_cert_path}
, #{connection_options}::JSONB , #{connection_options}::JSONB
@@ -193,13 +193,13 @@
<if test="description != null">DESCRIPTION = #{description},</if> <if test="description != null">DESCRIPTION = #{description},</if>
<if test="db_type != null">DB_TYPE = #{db_type},</if> <if test="db_type != null">DB_TYPE = #{db_type},</if>
<if test="host != null">HOST = #{host},</if> <if test="host != null">HOST = #{host},</if>
<if test="port != null">PORT = #{port},</if> <if test="port != null">PORT = #{port}::varchar,</if>
<if test="database_name != null">DATABASE_NAME = #{database_name},</if> <if test="database_name != null">DATABASE_NAME = #{database_name},</if>
<if test="username != null">USERNAME = #{username},</if> <if test="username != null">USERNAME = #{username},</if>
<if test="password != null">PASSWORD = #{password},</if> <if test="password != null">PASSWORD = #{password},</if>
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if> <if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if> <if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if> <if test="max_connections != null">MAX_CONNECTIONS = #{max_connections}::varchar,</if>
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if> <if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if> <if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if> <if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
@@ -208,13 +208,13 @@
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if> <if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
UPDATED_DATE = NOW() UPDATED_DATE = NOW()
</set> </set>
WHERE ID = #{id} WHERE ID = #{id}::varchar
</update> </update>
<!-- 삭제 --> <!-- 삭제 -->
<delete id="deleteExternalDbConnection" parameterType="map"> <delete id="deleteExternalDbConnection" parameterType="map">
DELETE FROM EXTERNAL_DB_CONNECTIONS DELETE FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id} WHERE ID = #{id}::varchar
<if test="company_code != null and company_code != &quot;*&quot;"> <if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if> </if>
@@ -704,11 +704,19 @@
</foreach> </foreach>
AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save' AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save'
AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL
<!-- table-like 화면 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
중 체크박스가 활성화된 것이 있으면 제외.
체크박스 config 경로가 두 가지로 구분된다:
- legacy table-list / v2-table-list : componentConfig.checkbox.enabled (boolean)
- canonical table : componentConfig.showCheckbox (boolean) -->
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_LIST SELECT 1 FROM SCREEN_LAYOUTS SL_LIST
WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID
AND SL_LIST.PROPERTIES->>'componentType' = 'table-list' AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list')
AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE AND (
(SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE
)
) )
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL
@@ -667,15 +667,15 @@
SET SET
PROPERTIES = JSONB_SET( PROPERTIES = JSONB_SET(
JSONB_SET( JSONB_SET(
SL.PROPERTIES, SL.PROPERTIES::JSONB,
'{widgetType}', TO_JSONB(#{component_id}::TEXT) '{widgetType}', TO_JSONB(#{component_id}::TEXT)
), ),
'{componentType}', TO_JSONB(#{component_id}::TEXT) '{componentType}', TO_JSONB(#{component_id}::TEXT)
) )::TEXT
FROM SCREEN_DEFINITIONS SD FROM SCREEN_DEFINITIONS SD
WHERE SL.SCREEN_ID = SD.SCREEN_ID WHERE SL.SCREEN_ID = SD.SCREEN_ID
AND SL.PROPERTIES->>'tableName' = #{table_name} AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
AND SL.PROPERTIES->>'columnName' = #{column_name} AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*') AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
</update> </update>
+143
View File
@@ -0,0 +1,143 @@
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
작성일: 2026-05-15
작성자: johngreen
관련:
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
## 목적
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
`CL.CODE_CATEGORY``CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
```
ERROR: column cl.code_info does not exist
```
본 089 마이그레이션이 `CODE_CATEGORY``CODE_INFO` 로 컬럼명을 안전하게 변경한다.
## 스키마
### MENU_INFO (V023)
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
### TABLE_TYPE_COLUMNS (V024)
| 변경 | 설명 |
|---|---|
| `CODE_CATEGORY``CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
## SQL
```sql
-- =================================================================
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
-- =================================================================
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
UPDATE MENU_INFO
SET IS_SOLUTION_ONLY = TRUE
WHERE IS_SOLUTION_ONLY = FALSE
AND MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
-- =================================================================
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
-- =================================================================
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_category'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'table_type_columns'
AND column_name = 'code_info'
) THEN
ALTER TABLE TABLE_TYPE_COLUMNS
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
END IF;
END $$;
```
## 멱등성
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
## 적용 방법
부팅 시 자동 적용 — 별도 작업 불필요.
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
```bash
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
-- 위 SQL 본문 붙여넣기
SQL
```
## 검증
```sql
-- V023
SELECT COLUMN_NAME FROM information_schema.columns
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
-- → 1 row
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
-- → 모두 IS_SOLUTION_ONLY = TRUE
-- V024
SELECT COLUMN_NAME FROM information_schema.columns
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
```
## 영향 범위
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
## 롤백
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
만약 필요하면:
```sql
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
```
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
+109
View File
@@ -0,0 +1,109 @@
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
작성일: 2026-05-15
작성자: johngreen
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
## 증상
```
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
```
## 원인
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
## 조치
### (1) 중복 행 정리
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
```sql
DELETE FROM TABLE_TYPE_COLUMNS
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
ORDER BY UPDATED_DATE DESC NULLS LAST,
id::bigint DESC
) AS rn
FROM TABLE_TYPE_COLUMNS
) r
WHERE r.rn > 1
);
```
실측(2026-05-15) 중복:
| DB | 중복 그룹 | 삭제될 row |
|---|---|---|
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
| `siflex_invyone` | 0 | 0 |
| `test01_invyone` | 0 | 0 |
| `test02_invyone` | 0 | 0 |
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
### (2) UNIQUE INDEX 추가
```sql
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
```
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
## 적용 방법
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
테넌트 DB 에 차례로 실행된다.
## 검증
```sql
-- 중복 없음
SELECT COUNT(*) FROM (
SELECT 1 FROM TABLE_TYPE_COLUMNS
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
) d;
-- → 0
-- 인덱스 존재
SELECT indexname FROM pg_indexes
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
-- → 1 row
```
브라우저 검증:
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
## 영향 범위
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
## 롤백
```sql
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
```
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
+81
View File
@@ -0,0 +1,81 @@
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
작성일: 2026-05-16
작성자: johngreen
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
## 배경
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
## 매핑
| Legacy | → | Standard | 사유 |
|---|---|---|---|
| `category` | → | `code` | commonCode 통합 의도와 일치 |
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
| `radio` | → | `code` | enum 선택 |
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
## 영향 범위 (실측 2026-05-16)
| DB | 갱신 row |
|---|---|
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
| `siflex_invyone` | 0 (테이블 비어있음) |
| `test01_invyone` | 0 |
| `test02_invyone` | 0 |
## SQL
```sql
UPDATE TABLE_TYPE_COLUMNS
SET INPUT_TYPE = CASE INPUT_TYPE
WHEN 'category' THEN 'code'
WHEN 'select' THEN 'code'
WHEN 'radio' THEN 'code'
WHEN 'checkbox' THEN 'code'
WHEN 'boolean' THEN 'code'
WHEN 'textarea' THEN 'text'
WHEN 'datetime' THEN 'date'
END,
UPDATED_DATE = NOW()
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
```
## 멱등성
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
## 적용 방법
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
## 검증
```sql
-- 화이트리스트 밖 row 0 이어야 함
SELECT input_type, COUNT(*) FROM table_type_columns
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
GROUP BY 1;
-- → 0 rows
```
## 후속 cleanup (별도 PR 거리)
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -64,6 +65,7 @@ import {
import { getCompanyList } from "@/lib/api/company"; import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company"; import { Company } from "@/types/company";
import { isManagementHost } from "@/lib/tenant/subdomain";
const RESOURCE_TYPE_CONFIG: Record< const RESOURCE_TYPE_CONFIG: Record<
string, string,
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
} }
export default function AuditLogPage() { export default function AuditLogPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const { user } = useAuth(); const { user } = useAuth();
const isSuperAdmin = user?.company_code === "*"; const isSuperAdmin = user?.company_code === "*";
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
setDetailOpen(true); setDetailOpen(true);
}; };
if (hostBlocked) return null;
return ( return (
<div className="flex h-full flex-col gap-4 p-4 md:p-6"> <div className="flex h-full flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -35,6 +35,7 @@ import {
} from "@/lib/api/batch"; } from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner"; import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { Pagination } from "@/components/common/Pagination";
import { useTabStore } from "@/stores/tabStore"; import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string { function cronToKorean(cron: string): string {
@@ -331,6 +332,10 @@ export default function BatchManagementPage() {
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null); const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const loadBatchConfigs = useCallback(async () => { const loadBatchConfigs = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -364,6 +369,9 @@ export default function BatchManagementPage() {
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]); useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
// 검색/필터 변경 시 1페이지로 리셋
useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]);
const handleRowClick = async (batchId: number) => { const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; } if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId); setExpandedBatch(batchId);
@@ -443,14 +451,22 @@ export default function BatchManagementPage() {
return true; return true;
}); });
// 페이지네이션 계산
const totalItems = filteredBatches.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const safePage = Math.min(currentPage, totalPages);
const startIdx = (safePage - 1) * itemsPerPage;
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
const pagedBatches = filteredBatches.slice(startIdx, endIdx);
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length; const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches; const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0; const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0; const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="w-full space-y-4 px-4 py-6 sm:px-6"> <div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-6 sm:px-6">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -534,8 +550,8 @@ export default function BatchManagementPage() {
</div> </div>
</div> </div>
{/* 배치 리스트 */} {/* 배치 리스트 - 자체 스크롤 */}
<div className="space-y-1.5"> <div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{loading && batchConfigs.length === 0 && ( {loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center"> <div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" /> <RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
@@ -549,7 +565,7 @@ export default function BatchManagementPage() {
</div> </div>
)} )}
{filteredBatches.map((batch) => { {pagedBatches.map((batch) => {
const batchId = batch.id!; const batchId = batch.id!;
const isExpanded = expandedBatch === batchId; const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId; const isExecuting = executingBatch === batchId;
@@ -674,6 +690,29 @@ export default function BatchManagementPage() {
})} })}
</div> </div>
{/* 페이지네이션 — 리스트 영역 아래 고정 */}
{!loading && (
<div className="shrink-0 rounded-lg border bg-card p-2 shadow-sm">
<Pagination
paginationInfo={{
currentPage: safePage,
totalPages,
totalItems,
itemsPerPage,
startItem: totalItems === 0 ? 0 : startIdx + 1,
endItem: endIdx,
}}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => {
setItemsPerPage(size);
setCurrentPage(1);
}}
showPageSizeSelector
pageSizeOptions={[10, 20, 50, 100]}
/>
</div>
)}
{/* 배치 타입 선택 모달 */} {/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && ( {isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
@@ -231,15 +231,15 @@ export default function ExternalConnectionsPage() {
) }, ) },
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true, { key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
render: (_v, row) => ( render: (_v, row) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }} onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
disabled={testingConnections.has(row.id!)} disabled={testingConnections.has(row.id!)}
className="h-9 text-sm"> className="h-7 px-2 text-xs">
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"} {testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
{testResults.has(row.id!) && ( {testResults.has(row.id!) && (
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}> <Badge variant={testResults.get(row.id!) ? "default" : "destructive"} className="text-[10px]">
{testResults.get(row.id!) ? "성공" : "실패"} {testResults.get(row.id!) ? "성공" : "실패"}
</Badge> </Badge>
)} )}
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
]; ];
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="space-y-6 p-6"> <div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4 sm:px-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="shrink-0 space-y-0.5 border-b pb-3">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p> <p className="text-xs text-muted-foreground"> REST API </p>
</div> </div>
{/* 탭 */} {/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList className="grid w-full max-w-[400px] grid-cols-2"> <TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2"> <TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
<Database className="h-4 w-4" /> <Database className="h-3.5 w-3.5" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-2"> <TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
<Globe className="h-4 w-4" /> <Globe className="h-3.5 w-3.5" />
REST API REST API
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* 데이터베이스 연결 탭 */} {/* 데이터베이스 연결 탭 */}
<TabsContent value="database" className="space-y-6"> <TabsContent value="database" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[260px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="연결명 또는 설명으로 검색..." placeholder="연결명 또는 설명으로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="h-8 pl-9 text-xs"
/> />
</div> </div>
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}> <Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]"> <SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
<SelectValue placeholder="DB 타입" /> <SelectValue placeholder="DB 타입" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{supportedDbTypes.map((type) => ( {supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type.value} value={type.value} className="text-xs">
{type.label} {type.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}> <Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]"> <SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
<SelectValue placeholder="상태" /> <SelectValue placeholder="상태" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => ( {ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value} className="text-xs">
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
isLoading={loading} isLoading={loading}
emptyMessage="등록된 연결이 없습니다" emptyMessage="등록된 연결이 없습니다"
skeletonCount={5} skeletonCount={5}
compact
scrollContainer
cardTitle={(c) => c.connection_name} cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>} cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
cardHeaderRight={(c) => ( cardHeaderRight={(c) => (
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}> <Badge variant={c.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{c.is_active === "Y" ? "활성" : "비활성"} {c.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
)} )}
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }} onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
disabled={testingConnections.has(c.id!)} disabled={testingConnections.has(c.id!)}
className="h-9 flex-1 gap-2 text-sm"> className="h-7 flex-1 gap-1 text-xs">
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"} {testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
setSelectedConnection(c); setSelectedConnection(c);
setSqlModalOpen(true); setSqlModalOpen(true);
}} }}
className="h-9 flex-1 gap-2 text-sm"> className="h-7 flex-1 gap-1 text-xs">
<Terminal className="h-4 w-4" /> <Terminal className="h-3.5 w-3.5" />
SQL SQL
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }} onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-9 flex-1 gap-2 text-sm"> className="h-7 flex-1 gap-1 text-xs">
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }} onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm"> className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
</TabsContent> </TabsContent>
{/* REST API 연결 탭 */} {/* REST API 연결 탭 */}
<TabsContent value="rest-api" className="space-y-6"> <TabsContent value="rest-api" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
<RestApiConnectionList /> <RestApiConnectionList />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { getCompaniesStats } from "@/lib/api/provisioning"; import { getCompaniesStats } from "@/lib/api/provisioning";
@@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio
import Wizard from "@/components/admin/provisioning/wizard/Wizard"; import Wizard from "@/components/admin/provisioning/wizard/Wizard";
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer"; import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport"; import { toCsvString, downloadCsv } from "@/lib/csvExport";
import { isManagementHost } from "@/lib/tenant/subdomain";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
* *
* /admin/userMng/companyList ( CRUD) . * /admin/userMng/companyList ( CRUD) .
* "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" . * "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" .
*
* 격리: 솔루션/ (solution.invyone.com, localhost ) .
* (qnc.invyone.com ) URL /main .
* SuperAdminGuard API .
*/ */
export default function SubdomainListPage() { export default function SubdomainListPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const [openKey, setOpenKey] = useState<string | null>(null); const [openKey, setOpenKey] = useState<string | null>(null);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all"); const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
@@ -51,6 +67,7 @@ export default function SubdomainListPage() {
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({ const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
queryKey: ["companies-stats"], queryKey: ["companies-stats"],
queryFn: getCompaniesStats, queryFn: getCompaniesStats,
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
refetchInterval: (query) => { refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초 // provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
const hasProvisioning = Array.isArray(query.state.data) const hasProvisioning = Array.isArray(query.state.data)
@@ -95,6 +112,12 @@ export default function SubdomainListPage() {
const provisCount = rows.filter((r) => r.db_status === "provisioning").length; const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length; const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
if (hostBlocked) {
return null;
}
return ( return (
<div <div
style={{ style={{
@@ -20,6 +20,7 @@ import {
Check, Check,
ChevronsUpDown, ChevronsUpDown,
Loader2, Loader2,
Pencil,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
@@ -119,6 +120,9 @@ export default function TableManagementPage() {
// 테이블 삭제 확인 다이얼로그 상태 // 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>(""); const [tableToDelete, setTableToDelete] = useState<string>("");
const [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false);
const [columnToDelete, setColumnToDelete] = useState<string>("");
const [isDeletingColumn, setIsDeletingColumn] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// PK/인덱스 관리 상태 // PK/인덱스 관리 상태
@@ -983,7 +987,20 @@ export default function TableManagementPage() {
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()), (table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
); );
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str); const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
const q = searchTerm.trim().toLowerCase();
// 검색 매치 강도: 0=정확, 1=시작, 2=포함 — 낮을수록 위
const matchScore = (t: typeof tables[number]) => {
if (!q) return 0;
const tn = (t.table_name ?? "").toLowerCase();
const dn = (t.display_name ?? "").toLowerCase();
if (tn === q || dn === q) return 0;
if (tn.startsWith(q) || dn.startsWith(q)) return 1;
return 2;
};
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
const sa = matchScore(a);
const sb = matchScore(b);
if (sa !== sb) return sa - sb;
const nameA = a.display_name || a.table_name; const nameA = a.display_name || a.table_name;
const nameB = b.display_name || b.table_name; const nameB = b.display_name || b.table_name;
const aKo = isKorean(nameA); const aKo = isKorean(nameA);
@@ -1187,6 +1204,37 @@ export default function TableManagementPage() {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}; };
// 컬럼 삭제 (DBeaver 방식 — FK 참조 있으면 Postgres 가 거부)
const handleDeleteColumnClick = (columnName: string) => {
setColumnToDelete(columnName);
setDeleteColumnDialogOpen(true);
};
const handleDeleteColumn = async () => {
if (!selectedTable || !columnToDelete) return;
setIsDeletingColumn(true);
try {
const result = await ddlApi.dropColumn(selectedTable, columnToDelete);
if (result.success) {
toast.success(`컬럼 '${columnToDelete}'이 삭제되었습니다.`);
if (selectedColumn === columnToDelete) setSelectedColumn(null);
await loadColumnTypes(selectedTable);
} else {
showErrorToast("컬럼 삭제에 실패했습니다", result.message, {
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
});
}
} catch (error) {
showErrorToast("컬럼 삭제에 실패했습니다", error, {
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
});
} finally {
setIsDeletingColumn(false);
setDeleteColumnDialogOpen(false);
setColumnToDelete("");
}
};
// 테이블 삭제 실행 // 테이블 삭제 실행
const handleDeleteTable = async () => { const handleDeleteTable = async () => {
if (!tableToDelete) return; if (!tableToDelete) return;
@@ -1385,8 +1433,8 @@ export default function TableManagementPage() {
</div> </div>
</div> </div>
{/* 3패널 메인 */} {/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
<div className="flex flex-1 overflow-hidden"> <div className="relative flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (240px) */} {/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r"> <div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
{/* 검색 */} {/* 검색 */}
@@ -1401,7 +1449,7 @@ export default function TableManagementPage() {
/> />
</div> </div>
{isSuperAdmin && ( {isSuperAdmin && (
<div className="mt-2 flex items-center justify-between border-b pb-2"> <div className="mt-2 flex min-h-9 items-center justify-between border-b pb-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Checkbox <Checkbox
checked={ checked={
@@ -1458,7 +1506,7 @@ export default function TableManagementPage() {
)} )}
<div <div
className={cn( className={cn(
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors", "group relative flex items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors",
isActive isActive
? "bg-accent text-foreground" ? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50", : "text-foreground/80 hover:bg-accent/50",
@@ -1488,13 +1536,13 @@ export default function TableManagementPage() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className={cn( <span className={cn(
"truncate text-[16px] leading-tight", "truncate text-[13px] leading-tight",
isActive ? "font-bold" : "font-medium", isActive ? "font-bold" : "font-medium",
)}> )}>
{table.display_name || table.table_name} {table.display_name || table.table_name}
</span> </span>
</div> </div>
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight"> <div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
{table.table_name} {table.table_name}
</div> </div>
</div> </div>
@@ -1551,26 +1599,24 @@ export default function TableManagementPage() {
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight" className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
/> />
) : ( ) : (
<div <div className="group flex items-center gap-1.5">
role="button" <span className="text-[15px] font-bold tracking-tight">
tabIndex={0} {tableLabel || (
onClick={() => { <span className="text-muted-foreground/60">{selectedTable}</span>
setEditingHeaderValue(tableLabel); )}
setEditingHeaderField("label"); </span>
}} <button
onKeyDown={(e) => { type="button"
if (e.key === "Enter" || e.key === " ") { onClick={() => {
e.preventDefault();
setEditingHeaderValue(tableLabel); setEditingHeaderValue(tableLabel);
setEditingHeaderField("label"); setEditingHeaderField("label");
} }}
}} className="text-muted-foreground/50 hover:text-foreground transition-colors"
className="-mx-2 cursor-text rounded px-2 py-0.5 text-[15px] font-bold tracking-tight hover:bg-muted/60 transition-colors" title="표시명 편집"
title="클릭하여 표시명 편집" aria-label="표시명 편집"
> >
{tableLabel || ( <Pencil className="h-3 w-3" />
<span className="text-muted-foreground/60">{selectedTable}</span> </button>
)}
</div> </div>
)} )}
{/* table_name (코드, 편집 불가) */} {/* table_name (코드, 편집 불가) */}
@@ -1596,26 +1642,24 @@ export default function TableManagementPage() {
className="mt-1 h-7 -mx-2 px-2 text-xs" className="mt-1 h-7 -mx-2 px-2 text-xs"
/> />
) : ( ) : (
<div <div className="group mt-0.5 flex items-center gap-1.5">
role="button" <span className="text-xs text-muted-foreground">
tabIndex={0} {tableDescription || (
onClick={() => { <span className="text-muted-foreground/50">+ </span>
setEditingHeaderValue(tableDescription); )}
setEditingHeaderField("description"); </span>
}} <button
onKeyDown={(e) => { type="button"
if (e.key === "Enter" || e.key === " ") { onClick={() => {
e.preventDefault();
setEditingHeaderValue(tableDescription); setEditingHeaderValue(tableDescription);
setEditingHeaderField("description"); setEditingHeaderField("description");
} }}
}} className="text-muted-foreground/50 hover:text-foreground transition-colors"
className="-mx-2 mt-0.5 cursor-text rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors" title="설명 편집"
title="클릭하여 설명 편집" aria-label="설명 편집"
> >
{tableDescription || ( <Pencil className="h-2.5 w-2.5" />
<span className="text-muted-foreground/50">+ </span> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -1655,7 +1699,7 @@ export default function TableManagementPage() {
<ColumnGrid <ColumnGrid
columns={columns} columns={columns}
selectedColumn={selectedColumn} selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn} onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => { onColumnChange={(columnName, field, value) => {
if (field === "is_unique") { if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName); const currentColumn = columns.find((c) => c.column_name === columnName);
@@ -1681,6 +1725,7 @@ export default function TableManagementPage() {
onIndexToggle={(columnName, checked) => onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked) handleIndexToggle(columnName, "index", checked)
} }
onDeleteColumn={handleDeleteColumnClick}
tables={tables} tables={tables}
referenceTableColumns={referenceTableColumns} referenceTableColumns={referenceTableColumns}
/> />
@@ -1690,10 +1735,14 @@ export default function TableManagementPage() {
)} )}
</div> </div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */} {/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
{selectedColumn && ( <div
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden"> className={cn(
<ColumnDetailPanel "bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
)}
>
<ColumnDetailPanel
column={columns.find((c) => c.column_name === selectedColumn) ?? null} column={columns.find((c) => c.column_name === selectedColumn) ?? null}
tables={tables} tables={tables}
referenceTableColumns={referenceTableColumns} referenceTableColumns={referenceTableColumns}
@@ -1719,8 +1768,7 @@ export default function TableManagementPage() {
codeInfoOptions={commonCodeOptions} codeInfoOptions={commonCodeOptions}
referenceTableOptions={referenceTableOptions} referenceTableOptions={referenceTableOptions}
/> />
</div> </div>
)}
</div> </div>
{/* DDL 모달 컴포넌트들 */} {/* DDL 모달 컴포넌트들 */}
@@ -1863,6 +1911,62 @@ export default function TableManagementPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 컬럼 삭제 확인 다이얼로그 */}
<Dialog open={deleteColumnDialogOpen} onOpenChange={setDeleteColumnDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
? .
</DialogDescription>
</DialogHeader>
{columnToDelete && (
<div className="space-y-3 sm:space-y-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<p className="text-destructive text-sm font-semibold"></p>
<p className="text-destructive/80 mt-1.5 text-sm">
<span className="font-mono font-bold">{selectedTable}.{columnToDelete}</span>
.
</p>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setDeleteColumnDialogOpen(false);
setColumnToDelete("");
}}
disabled={isDeletingColumn}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteColumn}
disabled={isDeletingColumn}
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isDeletingColumn ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
)} )}
@@ -1,5 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isManagementHost } from "@/lib/tenant/subdomain";
import { useCompanyManagement } from "@/hooks/useCompanyManagement"; import { useCompanyManagement } from "@/hooks/useCompanyManagement";
import { CompanyToolbar } from "@/components/admin/CompanyToolbar"; import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
import { CompanyTable } from "@/components/admin/CompanyTable"; import { CompanyTable } from "@/components/admin/CompanyTable";
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* *
*/ */
export default function CompanyPage() { export default function CompanyPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const { const {
// 데이터 // 데이터
companies, companies,
@@ -51,6 +64,8 @@ export default function CompanyPage() {
clearError, clearError,
} = useCompanyManagement(); } = useCompanyManagement();
if (hostBlocked) return null;
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
@@ -1,10 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import * as XLSX from "xlsx";
import { import {
ArrowDownToLine, ArrowDownToLine,
ArrowUpToLine, ArrowUpToLine,
Building2, Building2,
CheckCircle2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
@@ -12,6 +14,7 @@ import {
ChevronsUpDown, ChevronsUpDown,
Eye, Eye,
EyeOff, EyeOff,
FileDown,
Folder, Folder,
FolderOpen, FolderOpen,
FolderTree, FolderTree,
@@ -28,6 +31,7 @@ import {
Upload, Upload,
Users, Users,
X, X,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -152,11 +158,15 @@ export default function DeptMngListPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null); const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ───────────────────────── // ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState(""); const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
const [bulkUploading, setBulkUploading] = useState(false); const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]); const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkFileName, setBulkFileName] = useState<string>("");
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ─────────────────────── // ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null); const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
@@ -611,6 +621,251 @@ export default function DeptMngListPage() {
} }
}; };
// ─────────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 helpers
// ─────────────────────────────────────────────────────
const BULK_HEADERS_CREATE: Record<string, string> = {
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
"부서코드": "dept_code",
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
};
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
"부서코드": "dept_code",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
const currentHeaderMap = () =>
bulkTab === "create"
? BULK_HEADERS_CREATE
: bulkUpdateMode === "department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const currentBulkAction = (): departmentAPI.BulkAction =>
bulkTab === "create"
? "create"
: bulkUpdateMode === "department"
? "update_department"
: "update_manager";
const resetBulkData = useCallback(() => {
setBulkRows([]);
setBulkPreviewRows([]);
setBulkSelected(new Set());
setBulkFileName("");
}, []);
const openBulkModal = () => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkTab("create");
setBulkUpdateMode("department");
resetBulkData();
setBulkOpen(true);
};
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
const downloadBulkTemplate = () => {
const action = currentBulkAction();
const headerMap =
action === "create"
? BULK_HEADERS_CREATE
: action === "update_department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const columns = Object.keys(headerMap);
const example: Record<string, any> = {};
columns.forEach((c) => {
const snake = headerMap[c];
if (snake === "dept_name") example[c] = "경영지원본부";
else if (snake === "dept_code") example[c] = "DEPT_1";
else if (snake === "dept_type") example[c] = "dept";
else if (snake === "status") example[c] = "active";
else if (snake === "sort_order") example[c] = 10;
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
else example[c] = "";
});
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
ws["!cols"] = columns.map(() => ({ wch: 16 }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "부서");
const fileName =
action === "create"
? "부서_일괄등록_템플릿.xlsx"
: action === "update_department"
? "부서정보_일괄업데이트_템플릿.xlsx"
: "부서관리자_일괄업데이트_템플릿.xlsx";
XLSX.writeFile(wb, fileName);
};
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBulkFileName(file.name);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const ws = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
const headerMap = currentHeaderMap();
const rows = raw
.map((row) => {
const out: Record<string, any> = {};
for (const [korean, snake] of Object.entries(headerMap)) {
const v = row[korean];
if (v === undefined || v === null || v === "") continue;
if (MANAGER_KEYS.has(snake)) {
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) out[snake] = ids;
} else if (snake === "sort_order") {
const n = Number(v);
if (!Number.isNaN(n)) out[snake] = n;
} else {
out[snake] = String(v).trim();
}
}
return out;
})
.filter((r) => Object.keys(r).length > 0);
setBulkRows(rows);
setBulkPreviewRows([]);
setBulkSelected(new Set());
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
} catch (err: any) {
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
} finally {
// 동일 파일 재선택 가능하도록
e.target.value = "";
}
};
const handleBulkPreview = async () => {
if (bulkRows.length === 0) return;
setBulkBusy(true);
try {
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
if (res.success && (res as any).data) {
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
setBulkPreviewRows(rows);
// 기본: ok 인 row 만 선택
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
} else {
toast({
title: "미리보기 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const handleBulkApply = async () => {
const okSelected = bulkPreviewRows.filter(
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
);
if (okSelected.length === 0) {
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
return;
}
const payload = okSelected.map((r) => {
const { row_index, result, error_detail, ...rest } = r as any;
return rest as Record<string, any>;
});
setBulkBusy(true);
try {
const res =
bulkTab === "create"
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
if (res.success) {
const count =
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
toast({
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
description: `${count}건 처리됨`,
});
setBulkOpen(false);
resetBulkData();
await loadDepartments();
} else {
toast({
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const previewColumns = useMemo(() => {
if (bulkTab === "create") {
return [
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}
if (bulkUpdateMode === "department") {
return [
{ key: "dept_code", label: "부서코드" },
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
];
}
return [
{ key: "dept_code", label: "부서코드" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}, [bulkTab, bulkUpdateMode]);
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
const allOkSelected =
bulkOkCount > 0 &&
bulkPreviewRows
.filter((r) => r.result === "ok")
.every((r) => bulkSelected.has(r.row_index));
const isDirty = originalDraft const isDirty = originalDraft
? JSON.stringify(originalDraft) !== JSON.stringify(draft) ? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null); : isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
@@ -636,14 +891,7 @@ export default function DeptMngListPage() {
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 gap-1.5 text-xs" className="h-8 gap-1.5 text-xs"
onClick={() => { onClick={openBulkModal}
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkText("");
setBulkOpen(true);
}}
> >
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
@@ -1013,106 +1261,229 @@ export default function DeptMngListPage() {
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"} title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
/> />
{/* 일괄등록 */} {/* 일괄등록 / 일괄업데이트 */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}> <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-[640px]"> <DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle> </DialogTitle> <DialogTitle> / </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1.5 font-semibold">CSV </p>
<p className="text-muted-foreground">
: <code className="rounded bg-background px-1 py-0.5 font-mono">,,(dept|team|temp)</code>
</p>
<p className="mt-1 text-muted-foreground"> (DEPT_n).</p>
<p className="mt-1 text-muted-foreground">: <code className="rounded bg-background px-1 py-0.5 font-mono">,,dept</code></p>
</div>
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkOpen(false)}></Button>
<Button
disabled={bulkUploading || !bulkText.trim()}
onClick={async () => {
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return;
setBulkUploading(true);
const failures: { line: number; deptName: string; reason: string }[] = [];
let success = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cols = line.split(",").map((c) => c.trim());
const [dept_name, parent, dept_type] = cols;
if (!dept_name) {
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
continue;
}
try {
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
dept_name,
parent_dept_code: parent || null,
dept_type: (dept_type || "dept") as any,
} as any);
if (res.success) success++;
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
} catch (e: any) {
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
}
}
setBulkUploading(false);
toast({
title: `일괄등록 완료`,
description: `성공 ${success}건 / 실패 ${failures.length}`,
variant: failures.length > 0 ? "destructive" : "default",
});
if (failures.length > 0) {
setBulkFailures(failures);
} else {
setBulkOpen(false);
}
await loadDepartments();
}}
>
{bulkUploading ? "등록 중..." : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 일괄등록 실패 결과 */} <Tabs
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}> value={bulkTab}
<DialogContent className="max-w-[640px]"> onValueChange={(v) => {
<DialogHeader> setBulkTab(v as "create" | "update");
<DialogTitle> ({bulkFailures.length})</DialogTitle> resetBulkData();
</DialogHeader> }}
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30"> className="flex min-h-0 flex-1 flex-col"
<table className="w-full text-xs"> >
<thead className="bg-muted/50 sticky top-0"> <TabsList className="mb-2">
<tr> <TabsTrigger value="create"></TabsTrigger>
<th className="px-3 py-2 text-left font-semibold w-16"></th> <TabsTrigger value="update"></TabsTrigger>
<th className="px-3 py-2 text-left font-semibold"></th> </TabsList>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr> <TabsContent value="create" className="m-0 space-y-2">
</thead> <div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<tbody className="divide-y"> <p className="mb-1 font-semibold"> </p>
{bulkFailures.map((f, idx) => ( <ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<tr key={idx}> <li>[ 릿] .</li>
<td className="px-3 py-1.5 font-mono">{f.line}</td> <li> [] [].</li>
<td className="px-3 py-1.5">{f.deptName}</td> <li> (DEPT_n).</li>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td> <li> user_id (,) . 10/role.</li>
</tr> <li> 1000 .</li>
))} </ul>
</tbody> </div>
</table> </TabsContent>
</div>
<DialogFooter> <TabsContent value="update" className="m-0 space-y-2">
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button> <div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li> <code className="rounded bg-background px-1 font-mono">(dept_code)</code> .</li>
<li><b> </b>: /// . .</li>
<li><b> </b>: // . role .</li>
</ul>
</div>
<div className="flex items-center gap-3 px-1">
<Label className="text-xs font-semibold"> </Label>
<RadioGroup
value={bulkUpdateMode}
onValueChange={(v) => {
setBulkUpdateMode(v as "department" | "manager");
resetBulkData();
}}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs"> </Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs"> </Label>
</div>
</RadioGroup>
</div>
</TabsContent>
{/* 회사 + 파일 선택 (탭 공통) */}
<div className="mt-2 space-y-2 rounded-md border p-3">
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="text-xs">
<span className="font-mono">{selectedCompanyCode}</span>
{selectedCompany?.company_name && (
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
)}
</div>
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
<FileDown className="h-3.5 w-3.5" />
릿
</Button>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-2">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleBulkFile}
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
/>
{bulkFileName && (
<span className="shrink-0 text-xs text-muted-foreground">
{bulkRows.length}
</span>
)}
</div>
</div>
</div>
{/* 미리보기 테이블 */}
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
<span className="font-semibold">
({bulkSelected.size}/{bulkPreviewRows.length})
</span>
{bulkPreviewRows.length > 0 && (
<span className="text-muted-foreground">
<span className="text-emerald-600 dark:text-emerald-400"> {bulkOkCount}</span>
{" / "}
<span className="text-destructive"> {bulkErrCount}</span>
</span>
)}
</div>
<div className="min-h-[200px] flex-1 overflow-auto">
{bulkPreviewRows.length === 0 ? (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
<FileDown className="h-6 w-6 opacity-30" />
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-muted/60">
<tr>
<th className="w-9 px-2 py-1.5">
<Checkbox
checked={allOkSelected}
onCheckedChange={(c) => {
if (c) {
setBulkSelected(
new Set(
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
),
);
} else {
setBulkSelected(new Set());
}
}}
/>
</th>
{previewColumns.map((c) => (
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
))}
<th className="w-16 px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkPreviewRows.map((r) => {
const isErr = r.result === "error";
return (
<tr
key={r.row_index}
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
>
<td className="px-2 py-1.5">
<Checkbox
disabled={isErr}
checked={bulkSelected.has(r.row_index)}
onCheckedChange={(c) => {
setBulkSelected((prev) => {
const next = new Set(prev);
if (c) next.add(r.row_index);
else next.delete(r.row_index);
return next;
});
}}
/>
</td>
{previewColumns.map((c) => {
const v = (r as any)[c.key];
const display = (c as any).manager
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
: v != null && v !== "" ? String(v) : "-";
return (
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
{display}
</td>
);
})}
<td className="px-2 py-1.5">
{isErr ? (
<Badge variant="destructive" className="gap-1 text-[10px]">
<XCircle className="h-3 w-3" />
</Badge>
) : (
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)}
</td>
<td
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
title={r.error_detail || ""}
>
{r.error_detail || ""}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</Tabs>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
</Button>
<Button
variant="outline"
onClick={handleBulkPreview}
disabled={bulkBusy || bulkRows.length === 0}
>
{bulkBusy ? "검증 중..." : "미리보기"}
</Button>
<Button
onClick={handleBulkApply}
disabled={bulkBusy || bulkSelected.size === 0}
>
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -10,6 +10,7 @@ import { LayerDefinition } from "@/types/screen-management";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { isTableLikeComponent } from "@/lib/utils/componentTypeUtils";
import { EditModal } from "@/components/screen/EditModal"; import { EditModal } from "@/components/screen/EditModal";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
@@ -428,10 +429,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
} }
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드) // 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
const hasTableWidget = layout.components.some( // canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip
(comp: any) => const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table",
);
if (hasTableWidget) { if (hasTableWidget) {
return; return;
+3 -3
View File
@@ -92,10 +92,10 @@ export default function TestCardResponsivePage() {
{/* ── 1. v2-text-display (경량, 항상 동일) ── */} {/* ── 1. v2-text-display (경량, 항상 동일) ── */}
<div className="mb-2 text-base font-semibold text-slate-800"></div> <div className="mb-2 text-base font-semibold text-slate-800"></div>
{/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */} {/* ── 2. canonical stats (경량, container-type 만 부착) ── */}
<div <div
className="mb-3 grid grid-cols-4 gap-2 rounded border border-slate-200 bg-white p-2" className="mb-3 grid grid-cols-4 gap-2 rounded border border-slate-200 bg-white p-2"
style={{ containerType: "inline-size", containerName: "v2-aggregation-widget" }} style={{ containerType: "inline-size", containerName: "stats" }}
> >
{[ {[
{ label: "전체", v: "128" }, { label: "전체", v: "128" },
@@ -214,7 +214,7 @@ export default function TestCardResponsivePage() {
<b className="text-indigo-600">v2-table-search-widget</b> / (CSS @container ). <b className="text-indigo-600">v2-table-search-widget</b> / (CSS @container ).
</li> </li>
<li> <li>
(text-display, aggregation-widget, button-primary) <b>container-type: inline-size</b> . (text-display, stats, button-primary) <b>container-type: inline-size</b> .
Phase 2 . Phase 2 .
</li> </li>
</ul> </ul>
@@ -322,7 +322,7 @@ export function CreateTableModal({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden"> <DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
@@ -336,7 +336,7 @@ export function CreateTableModal({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="flex-1 space-y-6 overflow-y-auto pr-1">
{/* 테이블 기본 정보 */} {/* 테이블 기본 정보 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl"> <DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle className="text-base sm:text-lg"> <DialogTitle className="text-base sm:text-lg">
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="-mr-1 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 sm:space-y-4">
{/* 기본 정보 */} {/* 기본 정보 */}
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3> <h3 className="text-sm font-semibold sm:text-base"> </h3>
@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="shrink-0 gap-2 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}
@@ -219,27 +219,27 @@ export function RestApiConnectionList() {
return ( return (
<> <>
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{/* 검색 */} {/* 검색 */}
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[260px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
<Input <Input
placeholder="연결명 또는 URL로 검색..." placeholder="연결명 또는 URL로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="h-8 pl-9 text-xs"
/> />
</div> </div>
{/* 인증 타입 필터 */} {/* 인증 타입 필터 */}
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}> <Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
<SelectTrigger className="h-10 w-full sm:w-[160px]"> <SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
<SelectValue placeholder="인증 타입" /> <SelectValue placeholder="인증 타입" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{supportedAuthTypes.map((type) => ( {supportedAuthTypes.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type.value} value={type.value} className="text-xs">
{type.label} {type.label}
</SelectItem> </SelectItem>
))} ))}
@@ -248,12 +248,12 @@ export function RestApiConnectionList() {
{/* 활성 상태 필터 */} {/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}> <Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
<SelectTrigger className="h-10 w-full sm:w-[120px]"> <SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
<SelectValue placeholder="상태" /> <SelectValue placeholder="상태" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => ( {ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value} className="text-xs">
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
@@ -262,79 +262,79 @@ export function RestApiConnectionList() {
</div> </div>
{/* 추가 버튼 */} {/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
{/* 연결 목록 */} {/* 연결 목록 */}
{loading ? ( {loading ? (
<div className="flex h-64 items-center justify-center bg-card"> <div className="flex h-40 shrink-0 items-center justify-center rounded-lg border bg-card">
<div className="text-sm text-muted-foreground"> ...</div> <div className="text-xs text-muted-foreground"> ...</div>
</div> </div>
) : connections.length === 0 ? ( ) : connections.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center bg-card"> <div className="flex h-40 shrink-0 flex-col items-center justify-center rounded-lg border bg-card">
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> REST API </p> <p className="text-xs text-muted-foreground"> REST API </p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-card"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<Table> <Table divClassName="flex-1 overflow-auto">
<TableHeader> <TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow className="bg-background"> <TableRow className="border-b bg-muted hover:bg-muted">
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> URL</TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> URL</TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-right text-xs font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{connections.map((connection) => ( {connections.map((connection) => (
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50"> <TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<div className="max-w-[200px]"> <div className="max-w-[200px]">
<div className="truncate font-medium" title={connection.connection_name}> <div className="truncate font-medium" title={connection.connection_name}>
{connection.connection_name} {connection.connection_name}
</div> </div>
{connection.description && ( {connection.description && (
<div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}> <div className="text-muted-foreground mt-0.5 truncate text-[10px]" title={connection.description}>
{connection.description} {connection.description}
</div> </div>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
{(connection as any).company_name || connection.company_code} {(connection as any).company_name || connection.company_code}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm"> <TableCell className="h-10 px-3 font-mono text-xs">
<div className="max-w-[300px] truncate" title={connection.base_url}> <div className="max-w-[300px] truncate" title={connection.base_url}>
{connection.base_url} {connection.base_url}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge> <Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-center text-sm"> <TableCell className="h-10 px-3 text-center text-xs">
{Object.keys(connection.default_headers || {}).length} {Object.keys(connection.default_headers || {}).length}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}> <Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{connection.is_active === "Y" ? "활성" : "비활성"} {connection.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
{connection.last_test_date ? ( {connection.last_test_date ? (
<div> <div className="flex items-center gap-1.5">
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div> <span>{new Date(connection.last_test_date).toLocaleDateString()}</span>
<Badge <Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"} variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1" className="text-[10px]"
> >
{connection.last_test_result === "Y" ? "성공" : "실패"} {connection.last_test_result === "Y" ? "성공" : "실패"}
</Badge> </Badge>
@@ -343,41 +343,41 @@ export function RestApiConnectionList() {
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleTestConnection(connection)} onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)} disabled={testingConnections.has(connection.id!)}
className="h-9 text-sm" className="h-7 px-2 text-xs"
> >
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"} {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
{testResults.has(connection.id!) && ( {testResults.has(connection.id!) && (
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}> <Badge variant={testResults.get(connection.id!) ? "default" : "destructive"} className="text-[10px]">
{testResults.get(connection.id!) ? "성공" : "실패"} {testResults.get(connection.id!) ? "성공" : "실패"}
</Badge> </Badge>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-right"> <TableCell className="h-10 px-3 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleEditConnection(connection)} onClick={() => handleEditConnection(connection)}
className="h-8 w-8" className="h-7 w-7"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteConnection(connection)} onClick={() => handleDeleteConnection(connection)}
className="text-destructive hover:bg-destructive/10 h-8 w-8" className="text-destructive hover:bg-destructive/10 h-7 w-7"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
return n; return n;
}, [column]); }, [column]);
if (!column) return null;
const refTableOpts = useMemo(() => { const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s); const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length const rawSource = referenceTableOptions.length
? [...referenceTableOptions] ? [...referenceTableOptions]
: [ : [
{ value: "none", label: "없음" }, { value: "none", label: "없음" },
@@ -92,6 +90,14 @@ export function ColumnDetailPanel({
})), })),
]; ];
// value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌
const seen = new Set<string>();
const raw = rawSource.filter((o) => {
if (seen.has(o.value)) return false;
seen.add(o.value);
return true;
});
const noneOpt = raw.find((o) => o.value === "none"); const noneOpt = raw.find((o) => o.value === "none");
const rest = raw.filter((o) => o.value !== "none"); const rest = raw.filter((o) => o.value !== "none");
@@ -106,6 +112,10 @@ export function ColumnDetailPanel({
return noneOpt ? [noneOpt, ...rest] : rest; return noneOpt ? [noneOpt, ...rest] : rest;
}, [referenceTableOptions, tables]); }, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
if (!column) return null;
return ( return (
<div className="flex h-full w-full flex-col border-l bg-card"> <div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */} {/* 헤더 */}
@@ -372,7 +382,10 @@ export function ColumnDetailPanel({
<SelectValue placeholder="코드 선택" /> <SelectValue placeholder="코드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{[{ value: "none", label: "선택 안함" }, ...codeInfoOptions].map((opt) => ( {[
{ value: "none", label: "선택 안함" },
...codeInfoOptions.filter((opt) => opt.value !== "none"),
].map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
@@ -1,9 +1,15 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo } from "./types"; import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void; onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void;
onDeleteColumn?: (columnName: string) => void;
/** 호버 시 한글 라벨 표시용 (Badge title) */ /** 호버 시 한글 라벨 표시용 (Badge title) */
tables?: TableInfo[]; tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>; referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
@@ -57,6 +64,7 @@ export function ColumnGrid({
getColumnIndexState: externalGetIndexState, getColumnIndexState: externalGetIndexState,
onPkToggle, onPkToggle,
onIndexToggle, onIndexToggle,
onDeleteColumn,
tables, tables,
referenceTableColumns, referenceTableColumns,
}: ColumnGridProps) { }: ColumnGridProps) {
@@ -144,7 +152,7 @@ export function ColumnGrid({
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */} {/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium"> <div className="truncate text-xs font-medium">
{column.display_name && column.display_name !== column.column_name {column.display_name && column.display_name !== column.column_name
? `${column.display_name} (${column.column_name})` ? `${column.display_name} (${column.column_name})`
: column.column_name} : column.column_name}
@@ -285,20 +293,37 @@ export function ColumnGrid({
</button> </button>
</div> </div>
<div className="flex items-center justify-center"> <div
<Button className="flex items-center justify-center"
type="button" onClick={(e) => e.stopPropagation()}
variant="ghost" onPointerDown={(e) => e.stopPropagation()}
size="icon" onMouseDown={(e) => e.stopPropagation()}
className="h-8 w-8" >
onClick={(e) => { <DropdownMenu>
e.stopPropagation(); <DropdownMenuTrigger asChild>
onSelectColumn(column.column_name); <Button
}} type="button"
aria-label="상세 설정" variant="ghost"
> size="icon"
<MoreHorizontal className="h-4 w-4" /> className="h-8 w-8"
</Button> aria-label="컬럼 액션 메뉴"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => {
e.preventDefault();
onDeleteColumn?.(column.column_name);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
); );
@@ -92,6 +92,11 @@ export function ResponsiveDataView<T>({
}: ResponsiveDataViewProps<T>) { }: ResponsiveDataViewProps<T>) {
const rowHeight = compact ? "h-10" : "h-16"; const rowHeight = compact ? "h-10" : "h-16";
const headHeight = compact ? "h-9" : "h-12"; const headHeight = compact ? "h-9" : "h-12";
const bodyText = compact ? "text-xs" : "text-sm";
const headText = compact ? "text-xs" : "text-sm";
const cellPad = compact ? "px-3" : "";
const cardTitleClass = compact ? "text-sm" : "text-base";
const cardSubText = compact ? "text-xs" : "text-sm";
// cardFields 미지정 시 columns에서 자동 생성 // cardFields 미지정 시 columns에서 자동 생성
function resolveCardFields(item: T): RDVCardField<T>[] { function resolveCardFields(item: T): RDVCardField<T>[] {
if (typeof cardFields === "function") return cardFields(item); if (typeof cardFields === "function") return cardFields(item);
@@ -233,16 +238,20 @@ export function ResponsiveDataView<T>({
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */} {/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
<div <div
className={cn( className={cn(
"hidden rounded-lg border bg-card shadow-sm @3xl:block", // scrollContainer 모드는 flex 컨테이너로, 아니면 block 으로 표시 (둘 다 < @3xl 에서는 hidden)
// scrollContainer 모드: 부모 flex 안에서 가용 height 다 차지 + 자체 세로 스크롤 + sticky 헤더 scrollContainer
scrollContainer && "min-h-0 flex-1 overflow-y-auto overflow-x-auto", ? "hidden flex-col rounded-lg border bg-card shadow-sm @3xl:flex"
: "hidden rounded-lg border bg-card shadow-sm @3xl:block",
// 부모 flex 안에서 가용 height 다 차지. 실제 스크롤은 Table wrapper 가 담당
// (Table 컴포넌트가 만드는 내부 wrapper 에 flex-1 overflow-auto 를 주면 sticky header 가 그 wrapper 기준으로 작동).
scrollContainer && "min-h-0 flex-1 overflow-hidden",
tableContainerClassName tableContainerClassName
)} )}
> >
<Table> <Table divClassName={scrollContainer ? "flex-1 overflow-auto" : undefined}>
<TableHeader <TableHeader
className={cn( className={cn(
scrollContainer && "sticky top-0 z-10 bg-card" scrollContainer && "sticky top-0 z-10 bg-muted"
)} )}
> >
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> <TableRow className="border-b bg-muted/50 hover:bg-muted/50">
@@ -250,7 +259,7 @@ export function ResponsiveDataView<T>({
<TableHead <TableHead
key={col.key} key={col.key}
style={col.width ? { width: col.width } : undefined} style={col.width ? { width: col.width } : undefined}
className={cn(headHeight, "text-sm font-semibold")} className={cn(headHeight, cellPad, headText, "font-semibold")}
> >
{col.label} {col.label}
</TableHead> </TableHead>
@@ -258,7 +267,7 @@ export function ResponsiveDataView<T>({
{renderActions && ( {renderActions && (
<TableHead <TableHead
style={{ width: actionsWidth || "120px" }} style={{ width: actionsWidth || "120px" }}
className={cn(headHeight, "text-sm font-semibold")} className={cn(headHeight, cellPad, headText, "font-semibold")}
> >
{actionsLabel || "작업"} {actionsLabel || "작업"}
</TableHead> </TableHead>
@@ -278,7 +287,7 @@ export function ResponsiveDataView<T>({
{columns.map((col) => ( {columns.map((col) => (
<TableCell <TableCell
key={col.key} key={col.key}
className={cn(rowHeight, "text-sm", col.className)} className={cn(rowHeight, cellPad, bodyText, col.className)}
> >
{col.render {col.render
? col.render(getNestedValue(item, col.key), item, index) ? col.render(getNestedValue(item, col.key), item, index)
@@ -286,7 +295,7 @@ export function ResponsiveDataView<T>({
</TableCell> </TableCell>
))} ))}
{renderActions && ( {renderActions && (
<TableCell className={cn(rowHeight, "text-sm")}> <TableCell className={cn(rowHeight, cellPad, bodyText)}>
<div className="flex gap-2">{renderActions(item)}</div> <div className="flex gap-2">{renderActions(item)}</div>
</TableCell> </TableCell>
)} )}
@@ -319,11 +328,11 @@ export function ResponsiveDataView<T>({
{/* 카드 헤더 */} {/* 카드 헤더 */}
<div className="mb-3 flex items-start justify-between"> <div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold"> <h3 className={cn("truncate font-semibold", cardTitleClass)}>
{cardTitle(item)} {cardTitle(item)}
</h3> </h3>
{cardSubtitle && ( {cardSubtitle && (
<p className="mt-0.5 truncate text-sm text-muted-foreground"> <p className={cn("mt-0.5 truncate text-muted-foreground", cardSubText)}>
{cardSubtitle(item)} {cardSubtitle(item)}
</p> </p>
)} )}
@@ -337,7 +346,7 @@ export function ResponsiveDataView<T>({
{fields.length > 0 && ( {fields.length > 0 && (
<div className="space-y-1.5 border-t pt-3"> <div className="space-y-1.5 border-t pt-3">
{fields.map((field, i) => ( {fields.map((field, i) => (
<div key={i} className="flex justify-between text-sm"> <div key={i} className={cn("flex justify-between", cardSubText)}>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{field.label} {field.label}
</span> </span>
+15 -3
View File
@@ -41,10 +41,22 @@ export function ConnectionSvg({ children }: ConnectionSvgProps) {
); );
} }
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */ /**
* path mockup v3 EditCanvas orthogonal-with-rounded-corners
* from(x1,y1) to(x2,y2)
* y , (x1>x2) fallback ( backward )
*/
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string { export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1; // 역방향 (오른쪽→왼쪽): 직각 라우팅이 카드 위로 휘감으면 어색 → 베지어 사용
return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${x2},${y2}`; if (x2 < x1 - 20) {
const dx = x2 - x1;
return `M ${x1} ${y1} C ${x1 + Math.abs(dx) * 0.4} ${y1}, ${x2 - Math.abs(dx) * 0.4} ${y2}, ${x2} ${y2}`;
}
const sign = Math.sign(y2 - y1);
if (sign === 0) return `M ${x1} ${y1} L ${x2} ${y2}`;
const mx = (x1 + x2) / 2;
const r = Math.min(10, Math.abs(y2 - y1) / 2, Math.abs(x2 - x1) / 4);
return `M ${x1} ${y1} L ${mx - r} ${y1} Q ${mx} ${y1}, ${mx} ${y1 + sign * r} L ${mx} ${y2 - sign * r} Q ${mx} ${y2}, ${mx + r} ${y2} L ${x2} ${y2}`;
} }
/** 타입별 CSS 클래스 + 마커 */ /** 타입별 CSS 클래스 + 마커 */
@@ -0,0 +1,217 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Eye, Wrench, Save, FolderOpen, X, Database } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode';
import {
getBusinessRuleList,
getBusinessRuleInfo,
insertBusinessRule,
updateBusinessRule,
} from '@/lib/api/businessRule';
import { toast } from 'sonner';
interface ControlCardPanelProps {
dashboardId: string;
card: Record<string, any>;
}
/**
*
* - (: left:20, top:90, 320x240)
* (left:360, top:90 ) floating
* - : 카드명 / [ | ] / /
*/
export function ControlCardPanel({ dashboardId, card }: ControlCardPanelProps) {
const {
mode,
setMode,
setSelectedCardId,
ruleNodes,
ruleConnections,
activeRuleId,
setActiveRuleId,
setRuleNodes,
setRuleConnections,
} = useControlMode();
const [ruleList, setRuleList] = useState<Record<string, any>[]>([]);
const [showRuleList, setShowRuleList] = useState(false);
const cardLabel =
card.title ?? card.TITLE ?? card.template_name ?? card.TEMPLATE_NAME ?? '제목 없음';
const cardTable =
card.primary_table ?? card.PRIMARY_TABLE ?? card.source_table ?? card.SOURCE_TABLE ?? null;
const cardType =
card.component_type ?? card.COMPONENT_TYPE ?? card.template_type ?? card.TEMPLATE_TYPE ?? null;
// 편집 모드에서만 규칙 목록 로드
useEffect(() => {
if (mode !== 'edit') return;
getBusinessRuleList(dashboardId)
.then((res) => setRuleList(res?.list ?? res?.data ?? []))
.catch(() => setRuleList([]));
}, [mode, dashboardId]);
const handleLoadRule = useCallback(
async (ruleId: string) => {
try {
const detail = await getBusinessRuleInfo(ruleId);
if (!detail) {
toast.error('규칙을 찾을 수 없습니다');
return;
}
setRuleNodes(detail.nodes ?? []);
setRuleConnections(detail.connections ?? []);
setActiveRuleId(ruleId);
setShowRuleList(false);
toast.success(`"${detail.name ?? ruleId}" 로드됨`);
} catch {
toast.error('규칙 로드 실패');
}
},
[setRuleNodes, setRuleConnections, setActiveRuleId],
);
const handleSave = async () => {
if (ruleNodes.length === 0) {
toast.warning('저장할 노드가 없습니다');
return;
}
try {
const data = {
name: `${cardLabel} 규칙 ${new Date().toLocaleString('ko-KR')}`,
nodes: ruleNodes,
connections: ruleConnections,
card_id: card.card_id ?? card.CARD_ID ?? card.id,
};
if (activeRuleId) {
await updateBusinessRule(activeRuleId, data);
toast.success('규칙 저장됨');
} else {
const result = await insertBusinessRule(dashboardId, data);
if (result?.rule_id) setActiveRuleId(result.rule_id);
toast.success('규칙 생성됨');
}
} catch {
toast.error('저장 실패');
}
};
const handleSourceDragStart = (e: React.DragEvent) => {
if (!cardTable) return;
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name: cardTable }));
e.dataTransfer.effectAllowed = 'copy';
};
return (
<div className="ctrl-card-panel">
{/* 헤더 — "제어" + ✕ 닫기 (카드명은 좌측 카드 자체에 이미 보이므로 중복 X) */}
<div className="ctrl-card-panel-head">
<div className="ctrl-card-panel-icon"></div>
<div className="ctrl-card-panel-title-wrap">
<div className="ctrl-card-panel-title"></div>
{cardType && <div className="ctrl-card-panel-type">{cardType}</div>}
</div>
<button
className="ctrl-card-panel-close"
onClick={() => setSelectedCardId(null)}
title="제어 해제"
>
<X size={11} />
</button>
</div>
{/* 데이터 소스 칩 (드래그 가능, 편집 모드에서 룰 빌더로 추가) */}
{cardTable && (
<div className="ctrl-card-panel-source">
<span
className="ctrl-card-panel-source-chip"
draggable={mode === 'edit'}
onDragStart={mode === 'edit' ? handleSourceDragStart : undefined}
title={mode === 'edit' ? '드래그해서 룰 빌더에 추가' : '데이터 소스'}
>
<Database size={9} />
<span>{cardTable}</span>
</span>
</div>
)}
{/* 모드 토글 — 카드 컨텍스트 안의 segmented */}
<div className="ctrl-card-panel-mode">
<button
className={`ctrl-card-panel-mode-btn${mode === 'view' ? ' on' : ''}`}
onClick={() => setMode('view')}
title="읽기 — 자동 트리 자람"
>
<Eye size={10} />
<span></span>
</button>
<button
className={`ctrl-card-panel-mode-btn${mode === 'edit' ? ' on' : ''}`}
onClick={() => setMode('edit')}
title="편집 — 팔레트에서 직접 작성"
>
<Wrench size={10} />
<span></span>
</button>
</div>
{/* 편집 모드 액션 */}
{mode === 'edit' && (
<>
<div className="ctrl-card-panel-actions">
<div style={{ position: 'relative', flex: 1 }}>
<button
className="ctrl-card-panel-btn"
onClick={() => setShowRuleList(!showRuleList)}
disabled={ruleList.length === 0}
title="저장된 규칙 불러오기"
>
<FolderOpen size={10} />
<span>{ruleList.length > 0 ? ` (${ruleList.length})` : ''}</span>
</button>
{showRuleList && ruleList.length > 0 && (
<div className="ctrl-card-panel-dropdown">
{ruleList.map((rule) => {
const id = rule.rule_id ?? rule.RULE_ID;
const name = rule.name ?? rule.NAME ?? id;
const isActive = id === activeRuleId;
return (
<button
key={id}
className={`ctrl-card-panel-dropdown-item${isActive ? ' active' : ''}`}
onClick={() => handleLoadRule(id)}
>
{name}
</button>
);
})}
</div>
)}
</div>
<button
className="ctrl-card-panel-btn primary"
onClick={handleSave}
disabled={ruleNodes.length === 0}
title="현재 룰 저장"
>
<Save size={10} />
<span></span>
</button>
</div>
{ruleNodes.length > 0 && (
<div className="ctrl-card-panel-status">
{ruleNodes.length} · {ruleConnections.length}
</div>
)}
</>
)}
{mode === 'view' && (
<div className="ctrl-card-panel-hint">
</div>
)}
</div>
);
}
+156 -28
View File
@@ -1,11 +1,17 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useEffect, useRef } from 'react';
import { MousePointerClick } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode'; import { useControlMode } from './hooks/useControlMode';
import { ControlToolbar } from './ControlToolbar';
import { ControlPalette } from './ControlPalette';
import { FlowViewer } from './FlowViewer'; import { FlowViewer } from './FlowViewer';
import { RuleBuilder } from './RuleBuilder'; import { getMetaFields } from '@/lib/api/meta';
import type { FieldConfig } from '@/types/invyone-component';
import { ContextBar } from './ide/ContextBar';
import { LeftRail } from './ide/LeftRail';
import { RightRail } from './ide/RightRail';
import { Canvas } from './ide/Canvas';
import { StatusBar } from './ide/StatusBar';
import { CtrlFab } from './ide/CtrlFab';
import '@/styles/control-mode.css'; import '@/styles/control-mode.css';
interface ControlModeProps { interface ControlModeProps {
@@ -15,43 +21,165 @@ interface ControlModeProps {
} }
/** /**
* * Control IDE (v3 V3Takeover )
* , / *
* :
* 1) ON FlowViewer + ctrl-mode-hint + FAB
* 2) IDE 5- takeover (ContextBar / LeftRail / Canvas / RightRail / StatusBar)
* 3) ContextBar 4-segmented tabs READ / EDIT / RUN / HISTORY
* 4) ContextBar ( )
* 5) ContextBar OFF
*/ */
export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) { export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) {
const { active, mode } = useControlMode(); const active = useControlMode((s) => s.active);
const mode = useControlMode((s) => s.mode);
const selectedCardId = useControlMode((s) => s.selectedCardId);
const tablePositions = useControlMode((s) => s.tablePositions);
const flowEdges = useControlMode((s) => s.flowEdges);
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
const setRuleNodes = useControlMode((s) => s.setRuleNodes);
const setRuleConnections = useControlMode((s) => s.setRuleConnections);
const editInitDone = useRef<string | null>(null);
const selectedCard = selectedCardId
? cards.find((c) => (c.card_id ?? c.CARD_ID ?? c.id) === selectedCardId) ?? null
: null;
// edit 진입 시 자동 노드 등록:
// - view 에서 펼쳐진 테이블이 있으면 그것들 + 관계선
// - 없으면 primary_table 1개만 좌측에 카드로 등장 (사용자가 거기서 컬럼별 마우스 연결로 룰 작성)
useEffect(() => {
if (!active || mode !== 'edit' || !selectedCardId) return;
const key = `${selectedCardId}:${mode}:${Object.keys(tablePositions).join(',')}`;
if (editInitDone.current === key) return;
const { ruleNodes } = useControlMode.getState();
if (ruleNodes.length > 0) {
editInitDone.current = key;
return;
}
editInitDone.current = key;
const hasView = Object.keys(tablePositions).length > 0;
if (!hasView) return; // primary_table 자동 등장 X — 사용자가 LeftRail 에서 드래그할 때만 추가
// view 에서 펼쳐진 테이블 우선
if (hasView) {
const tableIdMap: Record<string, string> = {};
Object.keys(tablePositions).forEach((name) => { tableIdMap[name] = `tbl-${name}`; });
const xs = Object.values(tablePositions).map((p) => p.x);
const ys = Object.values(tablePositions).map((p) => p.y);
const minX = xs.length ? Math.min(...xs) : 0;
const minY = ys.length ? Math.min(...ys) : 0;
(async () => {
const newNodes: Record<string, any>[] = await Promise.all(
Object.entries(tablePositions).map(async ([name, pos]) => {
let columns: FieldConfig[] = [];
let label = name;
try {
const meta = await getMetaFields(name);
columns = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용)
label = meta.table_label ?? name;
} catch { /* 빈 컬럼 */ }
return {
id: tableIdMap[name], type: 'table', table_name: name, label,
x: pos.x - minX + 50, y: pos.y - minY + 50, columns,
};
}),
);
const newConns: Record<string, any>[] = [];
flowEdges.forEach((edge: Record<string, any>, i: number) => {
if (typeof edge.from === 'string' && edge.from.startsWith('CARD:')) return;
const fromId = tableIdMap[edge.from];
const toId = tableIdMap[edge.to];
if (!fromId || !toId) return;
newConns.push({
id: `conn-edit-${i}`,
from_node_id: fromId, from_port: 'out',
to_node_id: toId, to_port: 'in',
});
});
setRuleNodes(newNodes);
setRuleConnections(newConns);
})();
}
}, [active, mode, selectedCardId, tablePositions, flowEdges, setRuleNodes, setRuleConnections]);
// mode 가 view 로 돌아가거나 카드 변경 시 init guard 리셋
useEffect(() => {
if (mode === 'view' || !selectedCardId) {
editInitDone.current = null;
}
}, [mode, selectedCardId]);
// 캔버스에 mode 클래스 + 선택 카드 강조 클래스
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
cv.classList.toggle('control-mode-edit', active && mode === 'edit');
return () => {
cv.classList.remove('control-mode-edit');
};
}, [active, mode, canvasRef]);
// 선택된 카드 element 에 data-flow-active 토글
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
cv.querySelectorAll<HTMLElement>('[data-card-id]').forEach((el) => {
const id = el.dataset.cardId;
if (active && id === selectedCardId) {
el.setAttribute('data-flow-active', '1');
} else {
el.removeAttribute('data-flow-active');
}
});
return () => {
cv.querySelectorAll<HTMLElement>('[data-card-id]').forEach((el) => {
el.removeAttribute('data-flow-active');
});
};
}, [active, selectedCardId, canvasRef]);
if (!active) return null; if (!active) return null;
return ( return (
<> <>
{/* 제어 모드 툴바 */} {/* 카드 미선택 — FlowViewer (호버 토폴로지) + 안내 칩 + FAB */}
<ControlToolbar dashboardId={dashboardId} /> {!selectedCard && (
<>
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */} <FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
{mode === 'view' && ( <div className="ctrl-mode-hint">
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} /> <MousePointerClick size={13} style={{ color: 'rgb(0, 154, 150)' }} />
<span> <b>Control IDE</b> </span>
</div>
<CtrlFab onExit={toggleControlMode} />
</>
)} )}
{/* 편집 모드: 규칙 빌더 */} {/* 카드 선택 — IDE 5-분할 takeover */}
{mode === 'edit' && ( {selectedCard && (
<RuleBuilder canvasRef={canvasRef} /> <div className="ctrl-ide-shell">
<ContextBar
selectedCard={selectedCard}
onExit={() => setSelectedCardId(null)}
onCtrlExit={toggleControlMode}
/>
<LeftRail cards={cards} selectedCardId={selectedCardId!} />
<div className="ctrl-ide-canvas">
<Canvas card={selectedCard} canvasRef={canvasRef} dashboardId={dashboardId} />
</div>
<RightRail selectedCard={selectedCard} />
<StatusBar selectedCard={selectedCard} />
</div>
)} )}
</> </>
); );
} }
/** /** 호환성 stub — 외부에서 이름으로만 import 하는 경우 */
* wrapper
*/
export function ControlPaletteWrapper() { export function ControlPaletteWrapper() {
const { active, mode, addRuleNode } = useControlMode(); return null;
if (!active || mode !== 'edit') return null;
return (
<ControlPalette
onDropTable={() => {}}
onDropControl={() => {}}
/>
);
} }
+81 -37
View File
@@ -2,6 +2,7 @@
import { useRef, useCallback } from 'react'; import { useRef, useCallback } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getNodeIcon } from './schemas';
import { PortHandle } from './PortHandle'; import { PortHandle } from './PortHandle';
interface ControlNodeProps { interface ControlNodeProps {
@@ -11,80 +12,124 @@ interface ControlNodeProps {
} }
/** /**
* (16) mockup buildCtrlNode * (16) mockup V3RuleNode (cat-stripe + cat-chip header + label + summary + ports)
*/ */
export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) { export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode(); const { removeRuleNode, moveRuleNode, setConfigNodeId, configNodeId } = useControlMode();
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const def = CTRL_NODE_TYPES[node.type]; const def = CTRL_NODE_TYPES[node.type];
if (!def) return null; if (!def) return null;
const rgb = def.rgb;
const Ic = getNodeIcon(node.type);
const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }]; const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }];
const selected = configNodeId === node.id;
const dim = !!configNodeId && configNodeId !== node.id;
const handleHeadMouseDown = useCallback((e: React.MouseEvent) => { const handleNodeMouseDown = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
// port / del 버튼 클릭은 드래그 X
if (target.closest('.ctrl-io-port, button')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
const sl = node.x, st = node.y; const sl = node.x, st = node.y;
const el = nodeRef.current; const el = nodeRef.current;
if (el) el.style.zIndex = '30'; if (el) el.style.zIndex = '30';
let moved = false;
const mv = (ev: MouseEvent) => { const mv = (ev: MouseEvent) => {
moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy); const dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
moved = true;
moveRuleNode(node.id, sl + dx, st + dy);
}; };
const up = () => { const up = () => {
if (el) el.style.zIndex = '20'; if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', mv); document.removeEventListener('mousemove', mv);
document.removeEventListener('mouseup', up); document.removeEventListener('mouseup', up);
if (!moved) setConfigNodeId(node.id === configNodeId ? null : node.id);
}; };
document.addEventListener('mousemove', mv); document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up); document.addEventListener('mouseup', up);
}, [node.id, node.x, node.y, moveRuleNode]); }, [node.id, node.x, node.y, moveRuleNode, setConfigNodeId, configNodeId]);
// summary 표시 우선순위:
// 1. node.config.summary — NodeConfigPopover 가 저장한 한글 라벨 (예: "결재상태 = '결재완료'")
// 2. node.summary[0] — mock/seed 데이터의 summary
// 3. config entries fallback — { field, op, value, ... } 의 핵심 값을 chip 으로
// 4. '클릭하여 설정'
const formatVal = (v: any): string => {
if (v == null || v === '') return '';
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
if (typeof v === 'object') {
// fully qualified field { table, column }
if (v.column) return String(v.label ?? v.column);
return '';
}
return String(v);
};
const summary = (() => {
if (node.config?.summary) return String(node.config.summary);
if (node.summary?.[0]) return String(node.summary[0]);
if (node.config && Object.keys(node.config).length > 0) {
const parts = Object.entries(node.config)
.filter(([k]) => k !== 'summary')
.map(([k, v]) => `${k}: ${formatVal(v)}`)
.filter((s) => !s.endsWith(': '))
.slice(0, 2);
if (parts.length > 0) return parts.join(' · ');
}
return '클릭하여 설정';
})();
return ( return (
<div <div
ref={nodeRef} ref={nodeRef}
className="ctrl-action-node" className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
data-node-id={node.id} data-node-id={node.id}
data-node-type={node.type} data-node-type={node.type}
onMouseDown={handleNodeMouseDown}
style={{ style={{
left: node.x, left: node.x, top: node.y,
top: node.y, borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
['--na-rgb' as string]: def.rgb, boxShadow: selected
? `0 0 0 4px rgba(${rgb}, .14), 0 0 24px rgba(${rgb}, .22)`
: '0 4px 12px -4px rgba(0, 0, 0, .08)',
}} }}
> >
{/* Input 포트 */} {/* cat-color stripe */}
<PortHandle <div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
nodeId={node.id}
port="in"
type="in"
onDragEnd={onDragEnd}
/>
{/* 헤더 */} {/* body */}
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}> <div className="v3-rule-node-body">
<div className="ctrl-an-icon">{def.icon}</div> <div className="v3-rule-node-cat">
<span className="ctrl-an-name">{def.label}</span> <div className="v3-rule-node-cat-ico"
<button style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}>
className="ctrl-an-del" <Ic size={11} />
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }} </div>
> <span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
{def.label}
</button> </span>
</div> <button
type="button"
{/* 본문 */} className="ctrl-an-del"
<div title="삭제"
className="ctrl-an-body" onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
onClick={() => setConfigNodeId(node.id)} style={{ marginLeft: 'auto' }}
> >
<div className="ctrl-an-summary">
{node.config?.summary || '클릭하여 설정'} </button>
</div> </div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</div>}
</div> </div>
{/* Output 포트 */} {/* Input 포트 (좌측) */}
<PortHandle nodeId={node.id} port="in" type="in" onDragEnd={onDragEnd} />
{/* Output 포트 (우측, 다중 지원) — label 텍스트(✓/✗) 없이 색만으로 구분 (yes=초록, no=회색 dashed) */}
<div className="ctrl-an-ports-out"> <div className="ctrl-an-ports-out">
{outPorts.map((p) => ( {outPorts.map((p) => (
<PortHandle <PortHandle
@@ -93,7 +138,6 @@ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps)
port={p.port} port={p.port}
type="out" type="out"
cls={p.cls} cls={p.cls}
label={p.label}
onDragStart={onDragStart} onDragStart={onDragStart}
/> />
))} ))}
+190 -52
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { CTRL_NODE_TYPES } from './hooks/useControlMode'; import { Search, Star } from 'lucide-react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta'; import { getMetaTableList } from '@/lib/api/meta';
interface ControlPaletteProps { interface ControlPaletteProps {
@@ -11,73 +12,210 @@ interface ControlPaletteProps {
/** /**
* *
* mockup renderCtrlPalette * -
* - ()
* - DB max-height +
* - /
* - 16
*/ */
export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) {
// 시연용 추천 화이트리스트 (있을 만한 ERP 표준 테이블 + 메뉴 캡쳐에서 확인된 것)
const RECOMMENDED_TABLES = [
'user_info',
'department',
'role_info',
'menu_master',
'authority_master',
'approval_definitions',
'approval_requests',
'approval_lines',
'audit_log',
'attach_file_info',
];
// 도메인 아이콘 매핑 (prefix 기준)
function pickIcon(name: string): string {
const n = name.toLowerCase();
if (n.startsWith('user') || n === 'user_info') return '👤';
if (n.startsWith('department') || n.startsWith('dept')) return '🏢';
if (n.startsWith('role') || n.startsWith('authority')) return '🛡';
if (n.startsWith('menu')) return '📂';
if (n.startsWith('approval')) return '✋';
if (n.startsWith('audit') || n.startsWith('log')) return '📜';
if (n.startsWith('attach') || n.startsWith('file')) return '📎';
if (n.startsWith('mail')) return '📨';
if (n.startsWith('ai_')) return '🤖';
if (n.startsWith('order')) return '📦';
if (n.startsWith('project')) return '📋';
if (n.startsWith('barcode') || n.startsWith('label')) return '🏷';
if (n.startsWith('batch')) return '⚙';
if (n.startsWith('config') || n.startsWith('setting')) return '⚙';
return '🗂';
}
export function ControlPalette(_props: ControlPaletteProps) {
const [tables, setTables] = useState<Record<string, any>[]>([]); const [tables, setTables] = useState<Record<string, any>[]>([]);
const [search, setSearch] = useState('');
const mode = useControlMode((s) => s.mode);
const isEditMode = mode === 'edit';
useEffect(() => { useEffect(() => {
getMetaTableList().then(setTables).catch(() => {}); getMetaTableList().then(setTables).catch(() => {});
}, []); }, []);
// 검색 + 추천/일반 분리
const { recommended, others } = useMemo(() => {
const q = search.trim().toLowerCase();
const filtered = q
? tables.filter((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
return name.includes(q) || label.includes(q);
})
: tables;
const rec: Record<string, any>[] = [];
const oth: Record<string, any>[] = [];
filtered.forEach((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
if (RECOMMENDED_TABLES.includes(name)) rec.push(t);
else oth.push(t);
});
// 추천은 화이트리스트 순서 유지
rec.sort((a, b) => {
const an = String(a.table_name ?? a.TABLE_NAME ?? '').toLowerCase();
const bn = String(b.table_name ?? b.TABLE_NAME ?? '').toLowerCase();
return RECOMMENDED_TABLES.indexOf(an) - RECOMMENDED_TABLES.indexOf(bn);
});
return { recommended: rec, others: oth };
}, [tables, search]);
const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => { const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
e.dataTransfer.setData('text/plain', JSON.stringify(data)); e.dataTransfer.setData('text/plain', JSON.stringify(data));
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
}; };
const catLabels: Record<string, string> = { const catLabels: Record<string, string> = {
'트리거': '트리거', : '트리거',
'조건': '조건 / 분기', : '조건 / 분기',
'액션': '액션', : '액션',
'흐름': '흐름 제어', : '흐름 제어',
'연동': '외부 연동', : '외부 연동',
'기록': '기록', : '기록',
}; };
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록']; const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
return ( const renderTableItem = (t: Record<string, any>, isRecommended: boolean) => {
<div style={{ overflowY: 'auto', flex: 1 }}> const name = t.table_name ?? t.TABLE_NAME;
{/* DB 테이블 섹션 */} const rawLabel = t.table_label ?? t.TABLE_LABEL;
<div className="ctrl-palette-section">DB </div> const label = rawLabel && rawLabel !== name ? rawLabel : null;
{tables.map((t) => { const icon = pickIcon(String(name));
const name = t.table_name ?? t.TABLE_NAME; return (
const label = t.table_label ?? t.TABLE_LABEL ?? name; <div
return ( key={name}
<div className={`ctrl-palette-item${isRecommended ? ' ctrl-palette-item-rec' : ''}`}
key={name} draggable
className="ctrl-palette-item" title={`${label ?? name}${label ? ` (${name})` : ''} — 캔버스로 드래그`}
draggable onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
title={`${label} — 캔버스로 드래그`} >
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })} <span className="cp-icon">{icon}</span>
> <span className="cp-label">
<span className="cp-icon">🏢</span> <span className="cp-label-main">{label ?? name}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span> {label && <span className="cp-label-sub">{name}</span>}
</div> </span>
); {isRecommended && <Star size={9} className="cp-rec-star" />}
})} </div>
);
};
{/* 제어 노드 — 카테고리별 그룹 */} return (
{cats.map((cat) => { <div className="ctrl-palette">
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat); {/* 헤더 */}
if (!items.length) return null; <div className="ctrl-palette-header">
return ( <span className="ctrl-palette-header-title"> </span>
<div key={cat}> {!isEditMode && (
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div> <span className="ctrl-palette-header-hint"> </span>
{items.map(([type, def]) => ( )}
<div </div>
key={type}
className="ctrl-palette-item" {/* 검색박스 */}
draggable <div className="ctrl-palette-search-wrap">
title={`${def.label} — 캔버스로 드래그`} <Search size={11} className="ctrl-palette-search-icon" />
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })} <input
> type="text"
<span className="cp-icon">{def.icon}</span> className="ctrl-palette-search"
<span>{def.label}</span> placeholder="테이블 / 노드 검색…"
</div> value={search}
))} onChange={(e) => setSearch(e.target.value)}
</div> disabled={!isEditMode}
); />
})} </div>
<div
className={`ctrl-palette-scroll${!isEditMode ? ' disabled' : ''}`}
style={{ pointerEvents: isEditMode ? 'auto' : 'none' }}
>
{/* 주요 테이블 (자주 쓰는 ERP 표준) */}
{recommended.length > 0 && (
<>
<div className="ctrl-palette-section ctrl-palette-section-rec">
<Star size={9} style={{ marginRight: 3, fill: 'currentColor' }} />
<span className="ctrl-palette-section-count">{recommended.length}</span>
</div>
<div className="ctrl-palette-tables">
{recommended.map((t) => renderTableItem(t, true))}
</div>
</>
)}
{/* 전체 DB 테이블 (max-height + 내부 스크롤) */}
<div className="ctrl-palette-section">
DB
{others.length > 0 && <span className="ctrl-palette-section-count">{others.length}</span>}
</div>
<div className="ctrl-palette-tables ctrl-palette-tables-others">
{others.map((t) => renderTableItem(t, false))}
{others.length === 0 && search && (
<div className="ctrl-palette-empty"> </div>
)}
{others.length === 0 && !search && tables.length === 0 && (
<div className="ctrl-palette-empty"> </div>
)}
</div>
{/* 제어 노드 카테고리별 */}
{cats.map((cat) => {
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => {
if (d.cat !== cat) return false;
if (!search.trim()) return true;
const q = search.trim().toLowerCase();
return d.label.toLowerCase().includes(q);
});
if (!items.length) return null;
return (
<div key={cat}>
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
{items.map(([type, def]) => (
<div
key={type}
className="ctrl-palette-item"
draggable
title={`${def.label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
>
<span className="cp-icon">{def.icon}</span>
<span className="cp-label">
<span className="cp-label-main">{def.label}</span>
</span>
</div>
))}
</div>
);
})}
</div>
</div> </div>
); );
} }
+5 -1
View File
@@ -79,6 +79,7 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
flowEdges, flowEdges,
tablePositions, tablePositions,
setActiveFlowCard, setActiveFlowCard,
setSelectedCardId,
setFlowEdges, setFlowEdges,
setTablePositions, setTablePositions,
} = useControlMode(); } = useControlMode();
@@ -90,14 +91,17 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]); const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]); const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// 카드 클릭 → 흐름 표시 // 카드 클릭 → 흐름 표시 + 카드 선택 (selectedCardId 동기화)
const handleCardClick = useCallback(async (cardId: string) => { const handleCardClick = useCallback(async (cardId: string) => {
// 같은 카드 클릭 → 닫기 // 같은 카드 클릭 → 닫기
if (activeFlowCardId === cardId) { if (activeFlowCardId === cardId) {
clearFlow(); clearFlow();
setSelectedCardId(null);
return; return;
} }
setSelectedCardId(cardId);
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId); const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return; if (!card) return;
+403 -50
View File
@@ -1,20 +1,37 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
/** /**
* (mockup showNodeConfig/_buildCfgForm ) * Phase 2: schema-driven dropdown
* *
* 핵심: 노드와 /enum dropdown .
* - ( )
* - + sub
* - enum dropdown
* - multi-table optgroup namespace
* - fully qualified { table, column } (Phase 3 )
*/ */
export function NodeConfigPopover() { export function NodeConfigPopover() {
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode(); const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode();
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null; const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
const def = node ? CTRL_NODE_TYPES[node.type] : null; const def = node ? CTRL_NODE_TYPES[node.type] : null;
// 현재 노드와 연결된 테이블 노드들 (양방향 — from/to 어느 쪽이든)
const connectedTables = useMemo<Record<string, any>[]>(() => {
if (!configNodeId) return [];
const tableNodeIds = new Set<string>();
ruleConnections.forEach((c) => {
if (c.from_node_id === configNodeId) tableNodeIds.add(c.to_node_id);
if (c.to_node_id === configNodeId) tableNodeIds.add(c.from_node_id);
});
return ruleNodes.filter((n) => n.type === 'table' && tableNodeIds.has(n.id));
}, [configNodeId, ruleNodes, ruleConnections]);
useEffect(() => { useEffect(() => {
if (configNodeId && node) { if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true)); requestAnimationFrame(() => setOpen(true));
@@ -23,12 +40,14 @@ export function NodeConfigPopover() {
} }
}, [configNodeId, node]); }, [configNodeId, node]);
// 외부 클릭 닫기
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (!configNodeId) return; if (!configNodeId) return;
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return; const t = e.target as HTMLElement;
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return; if (t.closest('.ctrl-cfg-pop')) return;
if (t.closest('.v3-rule-node')) return;
if (t.closest('.tbl-node')) return;
if (t.closest('.ctrl-an-body')) return;
setConfigNodeId(null); setConfigNodeId(null);
}; };
document.addEventListener('click', handler); document.addEventListener('click', handler);
@@ -49,52 +68,291 @@ export function NodeConfigPopover() {
style={{ left: node.x + 172, top: node.y }} style={{ left: node.x + 172, top: node.y }}
> >
<div className="cfg-hd">{def.icon} {def.label} </div> <div className="cfg-hd">{def.icon} {def.label} </div>
<ConfigForm type={node.type} config={node.config ?? {}} onSave={handleSave} onClose={() => setConfigNodeId(null)} /> <ConfigForm
type={node.type}
config={node.config ?? {}}
connectedTables={connectedTables}
onSave={handleSave}
onClose={() => setConfigNodeId(null)}
/>
</div> </div>
); );
} }
function ConfigForm({ type, config, onSave, onClose }: { /* ─── Helpers ─── */
type: string; config: Record<string, any>;
interface ColumnMeta {
tableName: string;
tableLabel: string;
column: string;
label: string;
type: string;
options?: Array<{ value: string; label: string }>;
pk?: boolean;
}
/** 연결된 테이블들의 모든 컬럼을 flat 으로 + 표시 정보 포함 */
function flattenColumns(tables: Record<string, any>[]): ColumnMeta[] {
const out: ColumnMeta[] = [];
tables.forEach((t) => {
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
(t.columns ?? []).forEach((c: Record<string, any>) => {
const colName = c.column ?? c.name ?? c.COLUMN_NAME ?? '';
if (!colName) return;
out.push({
tableName: tName,
tableLabel: tLabel,
column: colName,
label: c.label ?? c.dname ?? colName,
type: c.type ?? c.dtype ?? 'text',
options: c.options,
pk: !!c.pk,
});
});
});
return out;
}
/** fully qualified id ↔ 객체 변환 */
function serializeField(field: any): string {
if (!field) return '';
if (typeof field === 'string') return field; // legacy
if (field.table && field.column) return `${field.table}|${field.column}`;
return '';
}
function deserializeField(s: string): { table: string; column: string } | null {
if (!s || !s.includes('|')) return null;
const [table, column] = s.split('|');
return { table, column };
}
/** field value (string or {table,column}) 으로 ColumnMeta 찾기 */
function findColumn(cols: ColumnMeta[], field: any): ColumnMeta | null {
if (!field) return null;
if (typeof field === 'string') return cols.find((c) => c.column === field) ?? null;
if (field.table && field.column) {
return cols.find((c) => c.tableName === field.table && c.column === field.column) ?? null;
}
return null;
}
/** 한글 라벨 표시 (field) */
function displayField(field: any, cols: ColumnMeta[]): string {
const col = findColumn(cols, field);
if (col) return col.label;
if (typeof field === 'string') return field;
if (field?.column) return field.column;
return '?';
}
/* ─── Reusable pickers ─── */
function FieldPicker({
tables, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
value: any;
onChange: (field: { table: string; column: string }) => void;
placeholder?: string;
}) {
const cols = useMemo(() => flattenColumns(tables), [tables]);
if (tables.length === 0) {
return <div className="cfg-empty"> </div>;
}
const currentId = serializeField(value);
return (
<select
className="cfg-sel"
value={currentId}
onChange={(e) => {
const f = deserializeField(e.target.value);
if (f) onChange(f);
}}
>
<option value="">{placeholder ?? '컬럼 선택...'}</option>
{tables.map((tbl) => {
const tName = tbl.table_name ?? tbl.tableName ?? '';
const tLabel = tbl.label ?? tName;
const tableCols = cols.filter((c) => c.tableName === tName);
if (tableCols.length === 0) return null;
const groupLabel = tLabel !== tName ? `${tLabel} · ${tName}` : tName;
return (
<optgroup key={tName} label={groupLabel}>
{tableCols.map((c) => {
const id = `${c.tableName}|${c.column}`;
const dispLabel = c.label !== c.column ? `${c.label} (${c.column})` : c.column;
return (
<option key={id} value={id}>
{dispLabel}{c.pk ? ' · PK' : ''}{c.type === 'select' ? ' · enum' : ''}
</option>
);
})}
</optgroup>
);
})}
</select>
);
}
function TablePicker({
tables, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
value: any;
onChange: (tableName: string) => void;
placeholder?: string;
}) {
// 자동 채움 — Strict 모드 안전 useEffect (committed lifecycle 에서만 실행)
const single = tables.length === 1
? (tables[0].table_name ?? tables[0].tableName ?? '')
: null;
useEffect(() => {
if (single && value !== single) onChange(single);
}, [single, value, onChange]);
if (tables.length === 0) {
return <div className="cfg-empty"> </div>;
}
// 1개면 자동 readonly
if (tables.length === 1) {
const t = tables[0];
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
return (
<div className="cfg-static">
<span className="cfg-static-main">{tLabel}</span>
{tLabel !== tName && <span className="cfg-static-sub">{tName}</span>}
<span className="cfg-static-hint">()</span>
</div>
);
}
// 2개+ 면 dropdown
const current = typeof value === 'string' ? value : (value?.table ?? '');
return (
<select className="cfg-sel" value={current} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder ?? '테이블 선택...'}</option>
{tables.map((t) => {
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
return (
<option key={tName} value={tName}>
{tLabel}{tLabel !== tName ? ` (${tName})` : ''}
</option>
);
})}
</select>
);
}
function ValuePicker({
tables, fieldRef, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
fieldRef: any; // 어느 컬럼의 값인지
value: any;
onChange: (v: string) => void;
placeholder?: string;
}) {
const cols = useMemo(() => flattenColumns(tables), [tables]);
const col = findColumn(cols, fieldRef);
// enum 컬럼이면 dropdown
if (col?.type === 'select' && col.options && col.options.length > 0) {
return (
<select className="cfg-sel" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder ?? '값 선택...'}</option>
{col.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}{opt.label !== opt.value ? ` (${opt.value})` : ''}
</option>
))}
</select>
);
}
// 기본 typed input
return (
<input
className="cfg-inp"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? (col ? `${col.label}` : '값 입력')}
/>
);
}
/* ─── ConfigForm ─── */
function ConfigForm({
type, config, connectedTables, onSave, onClose,
}: {
type: string;
config: Record<string, any>;
connectedTables: Record<string, any>[];
onSave: (summary: string, config: Record<string, any>) => void; onSave: (summary: string, config: Record<string, any>) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [vals, setVals] = useState<Record<string, any>>(config); const [vals, setVals] = useState<Record<string, any>>(config);
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v })); const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]);
const handleSave = () => { const handleSave = () => {
let summary = ''; let summary = '';
const fLabel = (f: any) => displayField(f, cols);
const tLabel = (tName: string) => {
const t = connectedTables.find((x) => (x.table_name ?? x.tableName) === tName);
return t?.label ?? tName ?? '?';
};
switch (type) { switch (type) {
case 'condition': case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`; summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`;
break; break;
case 'status-change': case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`; summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`;
break; break;
case 'auto-insert': case 'auto-insert':
summary = `${vals.table || '?'} INSERT`; summary = `${tLabel(vals.table)} INSERT`;
break; break;
case 'timer': case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`; summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break; break;
case 'notification': case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`; summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break; break;
case 'approval': case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`; summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`;
break; break;
case 'calculation': case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`; summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`;
break; break;
case 'webhook': case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`; summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break; break;
case 'validation': case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`; summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`;
break; break;
case 'log': case 'log':
summary = `로그: ${vals.content || '?'}`; summary = `로그: ${vals.content || '?'}`;
break; break;
case 'delete':
summary = `${tLabel(vals.table)} ${vals.mode === 'soft' ? 'soft delete' : 'hard delete'}`;
break;
case 'document':
summary = `${vals.template || '?'}${vals.format || 'pdf'}`;
break;
case 'delay':
summary = `${vals.amount || 0}${vals.unit || '분'} 대기`;
break;
case 'loop':
summary = vals.iterField ? `for each ${vals.iterField}` : `${vals.count || 1}회 반복`;
break;
case 'parallel':
summary = `${vals.branches || 2}개 병렬 실행`;
break;
case 'merge':
summary = vals.strategy === 'all' ? '모든 분기 대기 (all)' : '먼저 도착 (any)';
break;
default: default:
summary = vals.summary || '설정됨'; summary = vals.summary || '설정됨';
} }
@@ -103,7 +361,7 @@ function ConfigForm({ type, config, onSave, onClose }: {
return ( return (
<> <>
{renderFields(type, vals, set)} {renderFields(type, vals, set, connectedTables)}
<div className="cfg-ft"> <div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button> <button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button> <button className="cfg-btn" onClick={onClose}></button>
@@ -115,21 +373,25 @@ function ConfigForm({ type, config, onSave, onClose }: {
function renderFields( function renderFields(
type: string, type: string,
vals: Record<string, any>, vals: Record<string, any>,
set: (k: string, v: any) => void set: (k: string, v: any) => void,
tables: Record<string, any>[],
) { ) {
switch (type) { switch (type) {
/* ─── Phase 2 schema-driven 4종 ─── */
case 'condition': case 'condition':
return ( return (
<> <>
<CfgSec label="필드"> <CfgSec label="필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" /> <FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="비교할 컬럼 선택..." />
</CfgSec> </CfgSec>
<CfgSec label="연산자"> <CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)} <CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} /> options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} />
</CfgSec> </CfgSec>
<CfgSec label="값"> <CfgSec label="값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" /> <ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
onChange={(v) => set('value', v)} />
</CfgSec> </CfgSec>
</> </>
); );
@@ -137,27 +399,61 @@ function renderFields(
return ( return (
<> <>
<CfgSec label="대상 테이블"> <CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" /> <TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec> </CfgSec>
<CfgSec label="변경 필드"> <CfgSec label="변경 필드">
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} /> <FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="변경할 컬럼 선택..." />
</CfgSec> </CfgSec>
<CfgSec label="변경값"> <CfgSec label="변경값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" /> <ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
onChange={(v) => set('value', v)} placeholder="새 값" />
</CfgSec> </CfgSec>
</> </>
); );
case 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="결과 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="저장할 컬럼 선택..." />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)}
placeholder="QTY * UNIT_PRICE" />
</CfgSec>
</>
);
case 'validation':
return (
<>
<CfgSec label="대상 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="검증할 컬럼 선택..." />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec>
</>
);
/* ─── 기존 케이스 유지 (테이블 컬럼 의존성 없는 노드들) ─── */
case 'auto-insert': case 'auto-insert':
return ( return (
<CfgSec label="대상 테이블"> <CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" /> <TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec> </CfgSec>
); );
case 'timer': case 'timer':
return ( return (
<> <>
<CfgSec label="기준 필드"> <CfgSec label="기준 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" /> <FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="시간 기준 컬럼..." />
</CfgSec> </CfgSec>
<CfgSec label="경과 기준"> <CfgSec label="경과 기준">
<div style={{ display: 'flex', gap: '.3rem' }}> <div style={{ display: 'flex', gap: '.3rem' }}>
@@ -196,20 +492,6 @@ function renderFields(
</CfgSec> </CfgSec>
</> </>
); );
case 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="결과 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)} placeholder="QTY * UNIT_PRICE" />
</CfgSec>
</>
);
case 'webhook': case 'webhook':
return ( return (
<> <>
@@ -222,22 +504,91 @@ function renderFields(
</CfgSec> </CfgSec>
</> </>
); );
case 'validation': case 'log':
return ( return (
<> <>
<CfgSec label="대상 필드"> <CfgSec label="로그 레벨">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" /> <CfgSelect value={vals.level ?? 'info'} onChange={(v) => set('level', v)}
options={['info', 'warn', 'error', 'debug']} />
</CfgSec> </CfgSec>
<CfgSec label="검증 규칙"> <CfgSec label="내용">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)} <CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec> </CfgSec>
</> </>
); );
case 'log': case 'delete':
return ( return (
<CfgSec label="내용"> <>
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" /> <CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="삭제 방식">
<CfgSelect value={vals.mode ?? 'soft'} onChange={(v) => set('mode', v)}
options={['soft', 'hard']} />
</CfgSec>
<CfgSec label="조건 (WHERE)">
<CfgInput value={vals.where ?? ''} onChange={(v) => set('where', v)} placeholder="id = ?" />
</CfgSec>
</>
);
case 'document':
return (
<>
<CfgSec label="템플릿">
<CfgInput value={vals.template ?? ''} onChange={(v) => set('template', v)} placeholder="출고확인서.docx" />
</CfgSec>
<CfgSec label="출력 경로">
<CfgInput value={vals.output ?? ''} onChange={(v) => set('output', v)} placeholder="/docs/{id}.pdf" />
</CfgSec>
<CfgSec label="포맷">
<CfgSelect value={vals.format ?? 'pdf'} onChange={(v) => set('format', v)}
options={['pdf', 'docx', 'xlsx', 'html']} />
</CfgSec>
</>
);
case 'delay':
return (
<CfgSec label="지연 시간">
<div style={{ display: 'flex', gap: '.3rem' }}>
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
<CfgSelect value={vals.unit ?? '분'} onChange={(v) => set('unit', v)}
options={['초', '분', '시간', '일']} />
</div>
</CfgSec>
);
case 'loop':
return (
<>
<CfgSec label="반복 방식">
<CfgSelect value={vals.mode ?? 'count'} onChange={(v) => set('mode', v)}
options={['count', 'forEach', 'while']} />
</CfgSec>
{vals.mode === 'forEach' ? (
<CfgSec label="반복 대상 필드">
<FieldPicker tables={tables} value={vals.iterField} onChange={(f) => set('iterField', f)} />
</CfgSec>
) : vals.mode === 'while' ? (
<CfgSec label="조건식">
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="x < 10" />
</CfgSec>
) : (
<CfgSec label="횟수">
<CfgInput value={vals.count ?? '1'} onChange={(v) => set('count', v)} placeholder="1" />
</CfgSec>
)}
</>
);
case 'parallel':
return (
<CfgSec label="병렬 분기 수">
<CfgInput value={vals.branches ?? '2'} onChange={(v) => set('branches', v)} placeholder="2" />
</CfgSec>
);
case 'merge':
return (
<CfgSec label="합류 전략">
<CfgSelect value={vals.strategy ?? 'any'} onChange={(v) => set('strategy', v)}
options={['any', 'all']} />
</CfgSec> </CfgSec>
); );
default: default:
@@ -245,6 +596,8 @@ function renderFields(
} }
} }
/* ─── 공통 atoms ─── */
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) { function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="cfg-sec"> <div className="cfg-sec">
+4 -2
View File
@@ -17,15 +17,17 @@ interface PortHandleProps {
} }
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) { export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
// 단일 동그라미가 mousedown(연결 시작) + mouseup(연결 종료) 둘 다 받음
// (테이블 컬럼 port 처럼 시각적으로 하나만 보이는 경우)
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (type !== 'out' || !onDragStart) return; if (!onDragStart) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDragStart(nodeId, port, e); onDragStart(nodeId, port, e);
}; };
const handleMouseUp = (e: React.MouseEvent) => { const handleMouseUp = (e: React.MouseEvent) => {
if (type !== 'in' || !onDragEnd) return; if (!onDragEnd) return;
e.stopPropagation(); e.stopPropagation();
onDragEnd(nodeId, port); onDragEnd(nodeId, port);
}; };
+81 -34
View File
@@ -56,7 +56,7 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
} else { } else {
try { try {
const meta = await getMetaFields(d.name); const meta = await getMetaFields(d.name);
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8); cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용)
fieldCache[d.name] = cols; fieldCache[d.name] = cols;
} catch { /* 빈 필드 */ } } catch { /* 빈 필드 */ }
} }
@@ -88,13 +88,20 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
}, []); }, []);
// 노드 좌표에서 포트 위치 계산 // 노드 좌표에서 포트 위치 계산
const portPos = useCallback((nodeId: string, port: string) => { // dir: 'from' (출력측, 우측) | 'to' (입력측, 좌측) — 컬럼별 port 의 좌/우 결정용
const portPos = useCallback((nodeId: string, port: string, dir: 'from' | 'to' = 'from') => {
const node = ruleNodes.find((n) => n.id === nodeId); const node = ruleNodes.find((n) => n.id === nodeId);
if (!node) return null; if (!node) return null;
if (node.type === 'table') { if (node.type === 'table') {
if (port === 'in') return { x: node.x, y: node.y + 18 }; // 테이블 단위 단일 port — 카드 좌측(in) / 우측(out) 중앙
return { x: node.x + 200, y: node.y + 18 }; // (Phase 1: 컬럼별 port 폐기. 컬럼 선택은 NodeConfigPopover dropdown 에서)
void dir;
const cardW = 180;
const cardH = 70; // stripe + head + stats
const yMid = node.y + cardH / 2;
if (port === 'in') return { x: node.x, y: yMid };
return { x: node.x + cardW, y: yMid };
} }
// 제어 노드 // 제어 노드
@@ -114,14 +121,12 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
}, [ruleNodes]); }, [ruleNodes]);
return ( return (
<> <div
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */} className="rule-builder-canvas"
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
style={{ position: 'absolute', inset: 0, zIndex: 5 }} onDrop={handleDrop}
onDrop={handleDrop} onDragOver={handleDragOver}
onDragOver={handleDragOver} >
/>
{/* 연결선 SVG */} {/* 연결선 SVG */}
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}> <svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs> <defs>
@@ -137,32 +142,76 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
</defs> </defs>
{ruleConnections.map((c) => { {ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port); const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port); const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null; if (!f || !t) return null;
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes' // Phase 3: edge_type 별 stroke 분기 (yes/no 우선, 그 다음 edge_type)
: c.from_port === 'no' ? 'rule-conn-path conn-no' const portCls = c.from_port === 'yes' ? 'conn-yes'
: 'rule-conn-path'; : c.from_port === 'no' ? 'conn-no' : '';
const marker = c.from_port === 'yes' ? 'url(#arr-yes)' const edgeCls = c.edge_type ? `edge-${c.edge_type}` : '';
: c.from_port === 'no' ? 'url(#arr-no)' const cls = ['rule-conn-path', portCls, edgeCls].filter(Boolean).join(' ');
: 'url(#arr-rule)';
// 선 중간 라벨 — yes/no 같은 분기 + edge_type 시각화 (mockup v3 EditCanvas style)
const portLabel =
c.label ??
(c.from_port === 'yes' ? '예'
: c.from_port === 'no' ? '아니오'
: c.from_port === 'pass' ? '통과'
: c.from_port === 'fail' ? '실패'
: c.from_port === 'approved'? '승인'
: c.from_port === 'rejected'? '반려'
: c.from_port === 'each' ? '반복'
: c.from_port === 'done' ? '완료'
: null);
const labelColor = c.from_port === 'yes' ? 'var(--ctrl-green)'
: c.from_port === 'no' ? 'var(--v5-text-muted, #888)'
: c.from_port === 'pass' ? 'var(--ctrl-green)'
: c.from_port === 'fail' ? 'rgb(255, 71, 87)'
: c.from_port === 'approved' ? 'var(--ctrl-green)'
: c.from_port === 'rejected' ? 'var(--v5-text-muted, #888)'
: c.edge_type === 'table-mutation' ? 'rgb(253, 121, 168)'
: c.edge_type === 'execution-flow' ? 'var(--ctrl-primary)'
: c.edge_type === 'lookup' ? 'var(--ctrl-green)'
: 'var(--ctrl-cyan)';
const mx = (f.x + t.x) / 2;
const my = (f.y + t.y) / 2;
const labelW = Math.max(36, (portLabel?.length ?? 0) * 8 + 14);
return ( return (
<path <g key={c.id}>
key={c.id} <path d={bezierPath(f.x, f.y, t.x, t.y)} className={cls} />
d={bezierPath(f.x, f.y, t.x, t.y)} {portLabel && (
className={cls} <g transform={`translate(${mx}, ${my - 11})`}>
markerEnd={marker} <rect
/> x={-labelW / 2} y={-9}
width={labelW} height={18} rx={4}
fill="var(--v5-surface-solid)"
stroke={labelColor}
strokeWidth={1}
opacity={0.95}
/>
<text
y={4}
textAnchor="middle"
fontSize={10}
fontWeight={700}
fill={labelColor}
fontFamily="var(--v5-font-mono)"
>
{portLabel}
</text>
</g>
)}
</g>
); );
})} })}
</svg> </svg>
{/* 연결 삭제 뱃지 */} {/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => { {ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port); const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port); const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null; if (!f || !t) return null;
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2; const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2;
@@ -199,12 +248,10 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
y={node.y} y={node.y}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)} onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
style={{ overflow: 'visible' }} style={{ overflow: 'visible' }}
nodeId={node.id}
onPortDragStart={startDrag}
onPortDragEnd={finishDrag}
/> />
{/* I/O 포트 */}
<PortHandle nodeId={node.id} port="in" type="in" isTable onDragEnd={finishDrag} />
<div style={{ position: 'absolute', left: node.x + 194, top: node.y + 12 }}>
<PortHandle nodeId={node.id} port="out" type="out" isTable label="→" onDragStart={startDrag} />
</div>
</div> </div>
); );
} }
@@ -221,6 +268,6 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
{/* 설정 팝오버 */} {/* 설정 팝오버 */}
<NodeConfigPopover /> <NodeConfigPopover />
</> </div>
); );
} }
+82 -30
View File
@@ -1,31 +1,56 @@
'use client'; 'use client';
import { useRef, useCallback } from 'react'; import { useRef, useCallback } from 'react';
import { Database, X } from 'lucide-react';
import { PortHandle } from './PortHandle';
import { useControlMode } from './hooks/useControlMode';
interface TableNodeProps { interface TableNodeProps {
tableName: string; tableName: string;
label: string; label: string;
icon: string; /** 호환용 — 더 이상 사용 X (V3 컴팩트로 갈아엎으면서 이모지 폐기, Lucide Database 아이콘 고정) */
icon?: string;
columns: Record<string, any>[]; columns: Record<string, any>[];
x: number; x: number;
y: number; y: number;
style?: React.CSSProperties; style?: React.CSSProperties;
onMove?: (name: string, x: number, y: number) => void; onMove?: (name: string, x: number, y: number) => void;
/** 룰 노드 ID (PortHandle 연결용). 없으면 시각 카드만 (read-only) */
nodeId?: string;
onPortDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
onPortDragEnd?: (nodeId: string, port: string) => void;
} }
export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) { /**
* V3RuleNode
* - 180px , cyan top stripe, Lucide Database
* - + mono sub
* - stats row: `{N} cols · {K} FK`
* - · edge port 1 ( dropdown )
*/
export function TableNode({
tableName, label, columns, x, y, style, onMove, nodeId, onPortDragStart, onPortDragEnd,
}: TableNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!onMove) return; if (!onMove) return;
const target = e.target as HTMLElement;
if (target.closest('.ctrl-io-port, button')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
const sl = x, st = y; const sl = x, st = y;
const el = nodeRef.current; const el = nodeRef.current;
if (el) el.style.zIndex = '30'; if (el) el.style.zIndex = '30';
let moved = false;
const move = (ev: MouseEvent) => { const move = (ev: MouseEvent) => {
onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy); const dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
moved = true;
onMove(tableName, sl + dx, st + dy);
}; };
const up = () => { const up = () => {
if (el) el.style.zIndex = '20'; if (el) el.style.zIndex = '20';
@@ -36,42 +61,69 @@ export function TableNode({ tableName, label, icon, columns, x, y, style, onMove
document.addEventListener('mouseup', up); document.addEventListener('mouseup', up);
}, [onMove, tableName, x, y]); }, [onMove, tableName, x, y]);
const dtypeIcons: Record<string, string> = { // stats
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡', const totalCols = columns?.length ?? 0;
textarea: 'Aa', datetime: '📅', entity: '🔗', const fkCount = (columns ?? []).filter((c) => c.mark === 'FK' || c.type === 'entity').length;
}; const pkCount = (columns ?? []).filter((c) => c.pk).length;
const hasKoLabel = label && label !== tableName;
return ( return (
<div <div
ref={nodeRef} ref={nodeRef}
className="tbl-node" className="tbl-node tbl-node-compact"
data-table={tableName} data-table={tableName}
data-node-id={nodeId}
onMouseDown={handleMouseDown}
style={{ left: x, top: y, ...style }} style={{ left: x, top: y, ...style }}
> >
<div className="tbl-node-head" onMouseDown={handleMouseDown}> {/* cyan top stripe (V3RuleNode cat-stripe 와 일관) */}
<div className="tbl-icon">{icon}</div> <div className="tbl-node-stripe" />
<span className="tbl-name">{tableName}</span>
<span className="tbl-badge">{label}</span>
</div>
<div className="tbl-node-cols">
{columns.map((col) => {
const name = col.column ?? col.name ?? col.COLUMN_NAME ?? '';
const type = col.type ?? col.dtype ?? 'text';
const mark = col.pk ? 'PK' : col.mark === 'FK' ? 'FK' : '';
const portCls = mark === 'PK' ? 'pk' : mark === 'FK' ? 'fk' : '';
const displayName = col.label ?? col.dname ?? name;
const dtIcon = dtypeIcons[type] || 'Aa';
return ( <div className="tbl-node-head">
<div key={name} className="tbl-col" data-col={name}> <div className="tbl-node-ico"><Database size={11} /></div>
<div className={`tbl-port ${portCls}`} /> <div className="tbl-node-title">
<span className="tbl-col-name">{displayName}</span> <div className="tbl-node-label">{hasKoLabel ? label : tableName}</div>
<span className="tbl-col-type">{dtIcon} {type}</span> {hasKoLabel && <div className="tbl-node-sub">{tableName}</div>}
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>} </div>
</div> {nodeId && (
); <button
})} type="button"
className="tbl-node-del"
title="삭제"
onClick={(e) => { e.stopPropagation(); removeRuleNode(nodeId); }}
>
<X size={10} />
</button>
)}
</div> </div>
<div className="tbl-node-stats">
<span>{totalCols} cols</span>
{pkCount > 0 && <span>· {pkCount} PK</span>}
{fkCount > 0 && <span>· {fkCount} FK</span>}
</div>
{/* 좌·우 단일 port — 테이블 단위 연결 (컬럼 선택은 노드 설정창 dropdown) */}
{nodeId && (
<>
<PortHandle
nodeId={nodeId}
port="in"
type="in"
onDragEnd={onPortDragEnd}
onDragStart={onPortDragStart}
/>
<div className="ctrl-an-ports-out">
<PortHandle
nodeId={nodeId}
port="out"
type="out"
onDragStart={onPortDragStart}
onDragEnd={onPortDragEnd}
/>
</div>
</>
)}
</div> </div>
); );
} }
@@ -34,9 +34,11 @@ export const CTRL_NODE_TYPES: Record<string, {
interface ControlModeState { interface ControlModeState {
/** 제어 모드 활성 여부 */ /** 제어 모드 활성 여부 */
active: boolean; active: boolean;
/** 읽기 / 편집 모드 */ /** 읽기 / 편집 / 실행 / 이력 모드 (선택된 카드 컨텍스트 안의 토글, v3 — IDE 4-segmented tabs) */
mode: 'view' | 'edit'; mode: 'view' | 'edit' | 'run' | 'history';
/** 활성 흐름 — 클릭된 카드 ID */ /** 선택된 카드 ID — 카드 클릭 시 좌측 축소 + 그 옆에 제어 패널 */
selectedCardId: string | null;
/** 활성 흐름 — FlowViewer 내부 상태 (selectedCardId 와 동기화) */
activeFlowCardId: string | null; activeFlowCardId: string | null;
/** 흐름 엣지 배열 (BFS 결과) */ /** 흐름 엣지 배열 (BFS 결과) */
flowEdges: Record<string, any>[]; flowEdges: Record<string, any>[];
@@ -55,7 +57,8 @@ interface ControlModeState {
// 액션 // 액션
toggleControlMode: () => void; toggleControlMode: () => void;
setMode: (mode: 'view' | 'edit') => void; setMode: (mode: 'view' | 'edit' | 'run' | 'history') => void;
setSelectedCardId: (cardId: string | null) => void;
setActiveFlowCard: (cardId: string | null) => void; setActiveFlowCard: (cardId: string | null) => void;
setFlowEdges: (edges: Record<string, any>[]) => void; setFlowEdges: (edges: Record<string, any>[]) => void;
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void; setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
@@ -82,6 +85,7 @@ export const useControlMode = create<ControlModeState>()(
(set) => ({ (set) => ({
active: false, active: false,
mode: 'view', mode: 'view',
selectedCardId: null,
activeFlowCardId: null, activeFlowCardId: null,
flowEdges: [], flowEdges: [],
tablePositions: {}, tablePositions: {},
@@ -94,14 +98,29 @@ export const useControlMode = create<ControlModeState>()(
set((s) => ({ set((s) => ({
active: !s.active, active: !s.active,
mode: 'view', mode: 'view',
selectedCardId: null,
activeFlowCardId: null, activeFlowCardId: null,
flowEdges: [], flowEdges: [],
tablePositions: {}, tablePositions: {},
ruleNodes: [],
ruleConnections: [],
configNodeId: null, configNodeId: null,
})), })),
setMode: (mode) => set({ mode, configNodeId: null }), setMode: (mode) => set({ mode, configNodeId: null }),
setSelectedCardId: (cardId) =>
set({
selectedCardId: cardId,
// 카드 바꾸면 모드/룰 초기화 (각 카드는 자기 제어 컨텍스트)
mode: 'view',
activeFlowCardId: cardId,
ruleNodes: [],
ruleConnections: [],
activeRuleId: null,
configNodeId: null,
}),
setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }), setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
setFlowEdges: (edges) => set({ flowEdges: edges }), setFlowEdges: (edges) => set({ flowEdges: edges }),
@@ -152,6 +171,7 @@ export const useControlMode = create<ControlModeState>()(
set({ set({
active: false, active: false,
mode: 'view', mode: 'view',
selectedCardId: null,
activeFlowCardId: null, activeFlowCardId: null,
flowEdges: [], flowEdges: [],
tablePositions: {}, tablePositions: {},
@@ -59,24 +59,58 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
cleanup(); cleanup();
return; return;
} }
// 중복 방지 // ★ [HIGH] port direction validation — output → output 역방향 엣지 차단
if (ruleConnections.find((c) => // from_port 는 in/out/yes/no/pass/fail/approved/rejected 등 (output port 만 허용)
// to_port 는 in 만 허용 (input port 도착점)
// 단 테이블 port 는 양방향 (in/out 둘 다 가능, PortHandle 단일 dot 양방향화)
// → 노드 type 으로 분기
const stateForValidate = useControlMode.getState();
const fromNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === d.fromNodeId);
const toNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === toNodeId);
// 도착이 action 노드면 to_port 는 'in' 이어야 함 (action 노드는 좌측 in 만 mouseup 받음)
if (toNodeForVal && toNodeForVal.type !== 'table' && toPort !== 'in') {
cleanup();
return;
}
// 출발이 action 노드면 from_port 는 in 이 아니어야 함 (action 노드의 in 에서 시작은 의미 없음)
if (fromNodeForVal && fromNodeForVal.type !== 'table' && d.fromPort === 'in') {
cleanup();
return;
}
// 중복 방지 — getState() 로 최신 ruleConnections 사용 (render-captured stale 회피)
const currentConns = stateForValidate.ruleConnections;
if (currentConns.find((c) =>
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
)) { )) {
cleanup(); cleanup();
return; return;
} }
// Phase 3: edge_type 자동 추론 (위 validation 에서 가져온 노드 재사용)
// table → table = lookup (FK 참조)
// table → action = data-context (테이블 데이터를 노드 입력으로)
// action → table = table-mutation (노드 결과를 테이블에 저장/수정)
// action → action = execution-flow (실행 순서)
const fromIsTable = fromNodeForVal?.type === 'table';
const toIsTable = toNodeForVal?.type === 'table';
let edgeType: 'data-context' | 'execution-flow' | 'table-mutation' | 'lookup';
if (fromIsTable && toIsTable) edgeType = 'lookup';
else if (fromIsTable && !toIsTable) edgeType = 'data-context';
else if (!fromIsTable && toIsTable) edgeType = 'table-mutation';
else edgeType = 'execution-flow';
addRuleConnection({ addRuleConnection({
id: genConnId(), id: genConnId(),
from_node_id: d.fromNodeId, from_node_id: d.fromNodeId,
from_port: d.fromPort, from_port: d.fromPort,
to_node_id: toNodeId, to_node_id: toNodeId,
to_port: toPort, to_port: toPort,
edge_type: edgeType,
}); });
cleanup(); cleanup();
}, [addRuleConnection, ruleConnections]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [addRuleConnection]);
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
const d = dragRef.current; const d = dragRef.current;
@@ -89,6 +123,8 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
}, [canvasRef]); }, [canvasRef]);
// 마우스 이동/종료 전역 핸들러 // 마우스 이동/종료 전역 핸들러
// ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출
// (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패)
useEffect(() => { useEffect(() => {
const onMove = (e: MouseEvent) => { const onMove = (e: MouseEvent) => {
const d = dragRef.current; const d = dragRef.current;
@@ -99,10 +135,43 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
const x2 = e.clientX - cr.left + cv.scrollLeft; const x2 = e.clientX - cr.left + cv.scrollLeft;
const y2 = e.clientY - cr.top + cv.scrollTop; const y2 = e.clientY - cr.top + cv.scrollTop;
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2)); d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2));
// 호버 중인 port 강조
document.querySelectorAll('.ctrl-io-port.port-hover').forEach((el) => el.classList.remove('port-hover'));
const hoverPort = (e.target as HTMLElement)?.closest?.('.ctrl-io-port') as HTMLElement | null;
if (hoverPort && hoverPort.dataset.node !== d.fromNodeId) {
hoverPort.classList.add('port-hover');
}
}; };
const onUp = () => { const onUp = (e: MouseEvent) => {
if (dragRef.current) cleanup(); if (!dragRef.current) return;
// ① e.target 의 closest 로 port 찾기 (정확히 port 위에서 mouseup 한 경우)
let portEl = (e.target as HTMLElement | null)?.closest?.('.ctrl-io-port') as HTMLElement | null;
// ② 못 찾으면 마우스 좌표 주변 20px 내 가장 가까운 port 검색 (port 근처에서 mouseup)
if (!portEl) {
const candidates = document.querySelectorAll<HTMLElement>('.ctrl-io-port');
let best: { el: HTMLElement; dist: number } | null = null;
candidates.forEach((el) => {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const dx = e.clientX - cx, dy = e.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 24 && (!best || dist < best.dist)) {
best = { el, dist };
}
});
if (best) portEl = (best as { el: HTMLElement; dist: number }).el;
}
if (portEl) {
const toNodeId = portEl.dataset.node;
const toPort = portEl.dataset.port;
if (toNodeId && toPort) {
finishDrag(toNodeId, toPort);
return;
}
}
cleanup();
}; };
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
@@ -111,7 +180,7 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
}; };
}, [canvasRef, cleanup]); }, [canvasRef, cleanup, finishDrag]);
return { startDrag, finishDrag }; return { startDrag, finishDrag };
} }
+396
View File
@@ -0,0 +1,396 @@
'use client';
/**
* Canvas 4- (v3 V3Canvas / V3ViewCanvas / V3EditCanvas / V3RunCanvas / V3HistoryCanvas)
*
* view : 관계 (listRelations API)
* edit : ( RuleBuilder , 6 PanZoomStage )
* run : 단계별 (mock )
* history : 실행 (listExecutionHistory API)
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Table2, History as HistoryIcon, Play, Pause, SkipBack, SkipForward,
ChevronLeft, ChevronRight, Check,
} from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { PanZoomStage } from './PanZoomStage';
import { RuleBuilder } from '../RuleBuilder';
import {
listRelations, listExecutionHistory,
type TableRelation, type ExecutionRecord,
} from '@/lib/api/control';
interface CanvasProps {
card: Record<string, any>;
/** DashboardCanvas ref (호환용, IDE EditCanvas 는 자체 ref 사용) */
canvasRef: React.RefObject<HTMLDivElement | null>;
dashboardId: string;
}
export function Canvas({ card, dashboardId }: CanvasProps) {
const mode = useControlMode((s) => s.mode);
return (
<div className="ctrl-ide-canvas-inner">
{mode === 'view' && <ViewCanvas card={card} dashboardId={dashboardId} />}
{mode === 'edit' && <EditCanvas />}
{mode === 'run' && <RunCanvas />}
{mode === 'history' && <HistoryCanvas card={card} />}
</div>
);
}
/* ─── VIEW — 관계 트리 ─── */
function ViewCanvas({ card }: { card: Record<string, any>; dashboardId: string }) {
const tableName = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const cardTitle = card.title ?? card.TITLE ?? '카드';
const [rels, setRels] = useState<TableRelation[]>([]);
useEffect(() => {
if (!tableName) return;
let alive = true;
listRelations(tableName).then((r) => { if (alive) setRels(r); });
return () => { alive = false; };
}, [tableName]);
const W = 1000, H = 540;
const targets = useMemo(() => {
if (rels.length === 0) return [];
return rels.map((r, i) => {
const t = rels.length === 1 ? 0.5 : i / (rels.length - 1);
return { x: 750, y: 80 + t * 380, name: r.to, type: r.type, edgeLabel: r.label };
});
}, [rels]);
return (
<PanZoomStage width={W} height={H}>
<svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="v3-dots" width={16} height={16} patternUnits="userSpaceOnUse">
<circle cx={1} cy={1} r={0.7} fill="rgba(var(--v5-cyan-rgb), .16)" />
</pattern>
</defs>
<rect width={W} height={H} fill="url(#v3-dots)" />
{/* edges */}
{targets.map((t, i) => {
const isAuto = t.type === 'auto';
const rgb = isAuto ? '108,92,231' : '0,154,150';
const x1 = 250, y1 = H / 2, x2 = t.x - 100, y2 = t.y;
const mx = (x1 + x2) / 2;
return (
<g key={i}>
<path
d={`M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`}
stroke={`rgb(${rgb})`} strokeWidth={2} opacity={0.5}
fill="none" strokeDasharray={isAuto ? '0' : '6 4'}
/>
<g transform={`translate(${mx}, ${(y1 + y2) / 2 - 10})`}>
<rect x={-40} y={-10} width={80} height={20} rx={10}
fill="var(--v5-surface-solid)" stroke={`rgba(${rgb}, .45)`} strokeWidth={1.2} />
<text y={4} textAnchor="middle" fontSize={10} fontWeight={700} fill={`rgb(${rgb})`}>
{t.edgeLabel}
</text>
</g>
</g>
);
})}
{/* source highlight */}
<rect x={50} y={H / 2 - 56} width={200} height={112} rx={12}
fill="rgba(var(--v5-cyan-rgb), .05)" stroke="rgb(var(--v5-cyan-rgb))" strokeWidth={2} />
</svg>
{/* source label */}
<div style={{
position: 'absolute', left: 50, top: H / 2 - 56, width: 200, height: 112,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
}}>
<div className="ctrl-canvas-tag" style={{ color: 'rgb(0, 154, 150)' }}>SOURCE</div>
<div style={{ fontSize: '.85rem', fontWeight: 800, letterSpacing: '-.01em' }}>{cardTitle}</div>
<div className="ctrl-canvas-mono">{tableName || '—'}</div>
</div>
{/* target nodes */}
{targets.map((t) => (
<div key={t.name} className="ctrl-canvas-relnode" style={{
position: 'absolute', left: t.x - 100, top: t.y - 36, width: 200,
borderColor: t.type === 'auto'
? 'rgba(var(--v5-primary-rgb), .5)'
: 'rgba(var(--v5-cyan-rgb), .5)',
}}>
<div className="ctrl-canvas-tag" style={{
color: t.type === 'auto' ? 'rgb(var(--v5-primary-rgb))' : 'rgb(0, 154, 150)',
marginBottom: 5, display: 'flex', alignItems: 'center', gap: 5,
}}>
<Table2 size={10} />
{t.type === 'auto' ? 'AUTO' : 'FK'}
</div>
<div style={{ fontSize: '.78rem', fontWeight: 700, marginBottom: 4 }}>{t.name}</div>
<div className="ctrl-canvas-mono">
{t.type === 'auto' ? '동기화' : '참조'}
</div>
</div>
))}
{targets.length === 0 && (
<div className="ctrl-canvas-empty">
<small>API: GET /api/control/tables/{tableName}/relations</small>
</div>
)}
</PanZoomStage>
);
}
/* ─── EDIT — RuleBuilder 위임 (컬럼별 마우스 연결 + 노드 드래그 + 팔레트 드롭) ─── */
function EditCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
return (
<div ref={canvasRef} className="ctrl-edit-canvas-host">
<RuleBuilder canvasRef={canvasRef} />
</div>
);
}
/* ─── RUN — 단계별 실행 시각화 (mock 진행) ─── */
function RunCanvas() {
const ruleNodes = useControlMode((s) => s.ruleNodes);
const [playState, setPlayState] = useState<'paused' | 'playing'>('paused');
const [playStep, setPlayStep] = useState(0);
const totalSteps = Math.max(ruleNodes.length, 1);
const current = Math.min(playStep, totalSteps);
useEffect(() => {
if (playState !== 'playing') return;
if (current >= totalSteps) return;
const t = setTimeout(() => setPlayStep((s) => s + 1), 700);
return () => clearTimeout(t);
}, [playState, current, totalSteps]);
return (
<div className="ctrl-run-shell">
{/* top — playback controls */}
<div className="ctrl-run-top">
<div className={`ctrl-run-state ${playState}`}>
{playState === 'playing' ? <Play size={16} /> : <Pause size={16} />}
</div>
<div>
<div className={`ctrl-canvas-tag ${playState === 'playing' ? 'is-play' : 'is-pause'}`}>
{playState === 'playing' ? 'LIVE TRACE · 재생 중' : 'LIVE TRACE · 일시정지'}
</div>
<div style={{ fontSize: '.92rem', fontWeight: 700, marginTop: 2 }}>
{ruleNodes.length === 0 ? '룰 없음' : `노드 ${ruleNodes.length}`}
</div>
</div>
<div style={{ flex: 1 }} />
<div className="ctrl-run-btns">
<PlayBtn Ic={SkipBack} onClick={() => setPlayStep(0)} title="처음" />
<PlayBtn Ic={ChevronLeft} onClick={() => setPlayStep((s) => Math.max(0, s - 1))} title="이전" />
<PlayBtn
Ic={playState === 'playing' ? Pause : Play}
primary
onClick={() => setPlayState((p) => (p === 'playing' ? 'paused' : 'playing'))}
title={playState === 'playing' ? '일시정지' : '재생'}
/>
<PlayBtn Ic={ChevronRight} onClick={() => setPlayStep((s) => Math.min(totalSteps, s + 1))} title="다음" />
<PlayBtn Ic={SkipForward} onClick={() => setPlayStep(totalSteps)} title="끝" />
</div>
<div className="ctrl-run-counter">
<div className="ctrl-run-counter-num">{current}/{totalSteps}</div>
<div className="ctrl-canvas-mono">{Math.round((current / totalSteps) * 100)}%</div>
</div>
</div>
{/* progress */}
<div className="ctrl-run-progress">
<div className="ctrl-run-progress-bar" style={{ width: `${(current / totalSteps) * 100}%` }} />
</div>
{/* steps */}
<div className="ctrl-run-steps">
{ruleNodes.length === 0 && (
<div className="ctrl-canvas-empty">
EDIT
</div>
)}
{ruleNodes.map((n, i) => {
const def = CTRL_NODE_TYPES[n.type];
const rgb = def?.rgb ?? '108,92,231';
const done = i < current;
const active = i === current - 1 && playState === 'playing';
const pending = i >= current;
return (
<div
key={n.id}
className={`ctrl-run-step ${active ? 'is-active' : ''} ${done ? 'is-done' : ''} ${pending ? 'is-pending' : ''}`}
>
<div
className="ctrl-run-step-num"
style={{
background: done ? 'var(--v5-green)' : active ? `rgb(${rgb})` : 'var(--v5-bg-subtle)',
color: done || active ? '#fff' : 'var(--v5-text-muted)',
boxShadow: active ? `0 0 12px rgba(${rgb}, .5)` : 'none',
}}
>
{done ? <Check size={10} /> : i + 1}
</div>
<div
className="ctrl-run-step-ico"
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
>
{def?.icon ?? '?'}
</div>
<div>
<div style={{ fontSize: '.73rem', fontWeight: 700 }}>{n.label ?? def?.label ?? n.type}</div>
<div className="ctrl-canvas-mono">{n.summary?.[0] ?? ''}</div>
</div>
<LatencyBar ms={Math.round(20 + Math.random() * 60)} max={100} />
<span className={`ctrl-run-step-status ${active ? 'is-active' : ''}`}>
{done ? '완료' : active ? '진행 중…' : '대기'}
</span>
</div>
);
})}
</div>
</div>
);
}
function PlayBtn({
Ic, onClick, primary, title,
}: { Ic: any; onClick: () => void; primary?: boolean; title: string }) {
return (
<button
onClick={onClick}
title={title}
className={`ctrl-run-btn${primary ? ' primary' : ''}`}
>
<Ic size={11} />
</button>
);
}
function LatencyBar({ ms, max }: { ms: number; max: number }) {
const pct = Math.min(100, (ms / max) * 100);
const color = pct < 50 ? 'var(--v5-green)' : pct < 80 ? 'var(--v5-amber)' : 'var(--v5-red)';
return (
<div className="ctrl-latency-bar" title={`${ms}ms`}>
<div style={{ width: `${pct}%`, background: color }} />
<span>{ms}ms</span>
</div>
);
}
/* ─── HISTORY — 실행 이력 테이블 ─── */
function HistoryCanvas({ card }: { card: Record<string, any> }) {
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
const [items, setItems] = useState<ExecutionRecord[]>([]);
const [filter, setFilter] = useState<'all' | 'ok' | 'fail'>('all');
useEffect(() => {
if (!cardId) return;
let alive = true;
listExecutionHistory(cardId, { limit: 50 }).then((r) => { if (alive) setItems(r); });
return () => { alive = false; };
}, [cardId]);
const filtered = useMemo(() => {
if (filter === 'all') return items;
if (filter === 'ok') return items.filter((i) => i.ok);
return items.filter((i) => !i.ok);
}, [items, filter]);
const okCount = items.filter((i) => i.ok).length;
const failCount = items.length - okCount;
return (
<div className="ctrl-history-shell">
<div className="ctrl-history-top">
<div className="ctrl-history-tag">
<HistoryIcon size={11} />
EXECUTION HISTORY
</div>
<div className="ctrl-canvas-mono">
<b>{items.length}</b> · 24h
</div>
<div style={{ flex: 1 }} />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'ok' | 'fail')}
className="ctrl-history-select"
>
<option value="all"> ({items.length})</option>
<option value="ok"> ({okCount})</option>
<option value="fail"> ({failCount})</option>
</select>
</div>
<div className="ctrl-history-body">
<table className="ctrl-history-table">
<thead>
<tr>
<th></th>
<th>TS</th>
<th>TRIGGER</th>
<th>WHO</th>
<th style={{ textAlign: 'right' }}>STEPS</th>
<th style={{ textAlign: 'right' }}>LATENCY</th>
<th>RESULT</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map((ex) => (
<tr key={ex.id}>
<td>
<span
className="ctrl-history-dot"
style={{
background: ex.ok ? 'var(--v5-green)' : 'var(--v5-red)',
boxShadow: ex.ok
? '0 0 6px var(--v5-green)'
: '0 0 6px var(--v5-red)',
}}
/>
</td>
<td className="ctrl-history-mono">{ex.ts}</td>
<td className="ctrl-history-mono">{ex.trig}</td>
<td className="ctrl-history-mono ctrl-history-sec">{ex.who}</td>
<td className="ctrl-history-mono" style={{ textAlign: 'right' }}>{ex.steps}/8</td>
<td style={{ textAlign: 'right' }}>
<LatencyBar ms={ex.ms} max={400} />
</td>
<td>
<span className={`ctrl-history-result ${ex.ok ? 'ok' : 'fail'}`}>
{ex.ok ? 'OK' : 'FAIL'}
</span>
</td>
<td>
<button className="ctrl-history-more">
<ChevronRight size={11} />
</button>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={8}>
<div className="ctrl-canvas-empty" style={{ position: 'static' }}>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import {
Eye, Pencil, Play, History, Zap, LayoutDashboard,
Save, Undo2, FolderOpen, Search, X,
} from 'lucide-react';
import { useControlMode } from '../hooks/useControlMode';
import { listPresence, type PresenceUser } from '@/lib/api/control';
interface ContextBarProps {
selectedCard: Record<string, any>;
onExit: () => void; // 카드 닫기 (제어 유지)
onCtrlExit: () => void; // 제어 종료
}
const MODE_TABS = [
{ k: 'view' as const, Ic: Eye, label: 'READ' },
{ k: 'edit' as const, Ic: Pencil, label: 'EDIT' },
{ k: 'run' as const, Ic: Play, label: 'RUN' },
{ k: 'history' as const, Ic: History, label: 'HISTORY' },
];
export function ContextBar({ selectedCard, onExit, onCtrlExit }: ContextBarProps) {
const mode = useControlMode((s) => s.mode);
const setMode = useControlMode((s) => s.setMode);
const [presence, setPresence] = useState<PresenceUser[]>([]);
useEffect(() => {
let alive = true;
listPresence('').then((p) => { if (alive) setPresence(p); });
return () => { alive = false; };
}, []);
const tableName = selectedCard.primary_table ?? selectedCard.PRIMARY_TABLE ?? '';
const cardTitle = selectedCard.title ?? selectedCard.TITLE ?? '카드';
const dirtyCount = 0; // TODO 단계 6에서 store 도입
return (
<div className="ctrl-ide-ctxbar">
{/* 좌측 — 배지 + brumb */}
<div className="ctrl-ide-badge">
<Zap size={10} strokeWidth={2.5} />
CONTROL IDE
</div>
<span className="ctrl-ide-sep">/</span>
<button className="ctrl-ide-tool" disabled>
<LayoutDashboard size={11} />
</button>
<span className="ctrl-ide-sep">/</span>
<div className="ctrl-ide-crumb-card">
{cardTitle}
{tableName && <span className="ctrl-ide-crumb-tbl">{tableName}</span>}
</div>
<div style={{ flex: 1 }} />
{/* presence stack — 빈 배열이면 미렌더 */}
{presence.length > 0 && (
<>
<div className="ctrl-presence">
{presence.slice(0, 4).map((p, i) => (
<span
key={i}
className={`ctrl-presence-avatar${p.mode === 'edit' ? ' is-edit' : ''}`}
style={{ background: `rgb(${p.color})` }}
title={`${p.name} · ${p.mode === 'edit' ? '편집중' : '보는중'}`}
>
{p.short}
</span>
))}
{presence.length > 4 && (
<span className="ctrl-presence-more">+{presence.length - 4}</span>
)}
</div>
<span className="ctrl-ide-vsep" aria-hidden="true" />
</>
)}
{/* cmd-k */}
<button className="ctrl-ide-tool ctrl-ide-cmdk" title="명령 팔레트 (⌘K)">
<Search size={10} />
K
</button>
<span className="ctrl-ide-vsep" aria-hidden="true" />
{/* mode 4-segmented tabs */}
<div className="ctrl-ide-mode-tabs">
{MODE_TABS.map(({ k, Ic, label }) => (
<button
key={k}
className={`ctrl-ide-mode-tab${mode === k ? ' on' : ''}`}
onClick={() => setMode(k)}
>
<Ic size={10} />
{label}
</button>
))}
</div>
{/* toolbar */}
<button className="ctrl-ide-tool" title="불러오기">
<FolderOpen size={11} />
<span></span>
</button>
<button className={`ctrl-ide-tool${dirtyCount > 0 ? ' primary' : ''}`} title="저장">
<Save size={11} />
<span>{dirtyCount > 0 ? `저장 · ${dirtyCount}` : '저장'}</span>
</button>
<button className="ctrl-ide-tool" title="되돌리기">
<Undo2 size={11} />
</button>
<span className="ctrl-ide-vsep" aria-hidden="true" />
{/* 카드 닫기 (제어 유지) */}
<button
onClick={onExit}
title="닫고 대시보드로 (제어 유지)"
className="ctrl-ide-tool ctrl-ide-close"
>
<X size={11} />
<span></span>
</button>
{/* 제어 종료 */}
<button
onClick={onCtrlExit}
title="제어 모드 종료"
className="ctrl-ide-tool ctrl-ide-exit"
>
</button>
</div>
);
}
@@ -0,0 +1,21 @@
'use client';
import { X, Zap } from 'lucide-react';
interface CtrlFabProps {
onExit: () => void;
}
export function CtrlFab({ onExit }: CtrlFabProps) {
return (
<div className="ctrl-fab">
<span className="ctrl-fab-dot" />
<Zap size={11} strokeWidth={2.5} />
<span> </span>
<span className="ctrl-fab-sep" />
<button onClick={onExit} className="ctrl-fab-x" title="제어 종료">
<X />
</button>
</div>
);
}
@@ -0,0 +1,229 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, LayoutDashboard, Boxes, Database, ChevronRight } from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { NODE_CATEGORIES, ctrlCatToV3, getNodeIcon } from '../schemas';
import { getMetaTableList } from '@/lib/api/meta';
interface LeftRailProps {
cards: Record<string, any>[];
selectedCardId: string;
}
/**
* LeftRail v3 V3LeftRail + invyone
*
* :
* 1)
* 2) DB , name . /
* 3) (edit )
*
* dataTransfer 포맷: text/plain = JSON({ kind: 'table'|'control', name|type })
*/
export function LeftRail({ cards, selectedCardId }: LeftRailProps) {
const mode = useControlMode((s) => s.mode);
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
const [query, setQuery] = useState('');
const [tables, setTables] = useState<Record<string, any>[]>([]);
useEffect(() => {
if (mode !== 'edit') return;
getMetaTableList().then(setTables).catch(() => {});
}, [mode]);
const { sortedTables, nodeEntries } = useMemo(() => {
const q = query.trim().toLowerCase();
// 테이블 필터 + 정렬
const filtered = q
? tables.filter((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
return name.includes(q) || label.includes(q);
})
: tables;
// 정렬: 한글 label 있는 것 가나다순 → label 없는 것 (영문 name) 알파벳순
const koCollator = new Intl.Collator('ko');
const sorted = [...filtered].sort((a, b) => {
const aLabel = String(a.table_label ?? a.TABLE_LABEL ?? '');
const bLabel = String(b.table_label ?? b.TABLE_LABEL ?? '');
const aName = String(a.table_name ?? a.TABLE_NAME ?? '');
const bName = String(b.table_name ?? b.TABLE_NAME ?? '');
const aHasKo = !!aLabel && aLabel !== aName;
const bHasKo = !!bLabel && bLabel !== bName;
if (aHasKo !== bHasKo) return aHasKo ? -1 : 1;
if (aHasKo) return koCollator.compare(aLabel, bLabel);
return aName.localeCompare(bName);
});
// 노드 필터
const filteredNodes = Object.entries(CTRL_NODE_TYPES).filter(([type, def]) => {
if (!q) return true;
return def.label.toLowerCase().includes(q) || type.toLowerCase().includes(q);
});
return { sortedTables: sorted, nodeEntries: filteredNodes };
}, [tables, query]);
/** 드래그 시작 — text/plain JSON, EditCanvas.handleCanvasDrop 와 호환 */
const onDragTable = (e: React.DragEvent, name: string) => {
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name }));
e.dataTransfer.effectAllowed = 'copy';
};
const onDragNode = (e: React.DragEvent, type: string) => {
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'control', type }));
e.dataTransfer.effectAllowed = 'copy';
};
const renderTableItem = (t: Record<string, any>) => {
const name = t.table_name ?? t.TABLE_NAME;
const rawLabel = t.table_label ?? t.TABLE_LABEL;
const hasKoLabel = !!rawLabel && rawLabel !== name;
return (
<div
key={name}
className="ctrl-rail-tbl"
draggable
title={`${rawLabel ?? name}${hasKoLabel ? ` (${name})` : ''} — 캔버스로 드래그`}
onDragStart={(e) => onDragTable(e, name)}
>
<Database size={11} className="ctrl-rail-tbl-ico" />
<span className="ctrl-rail-tbl-main">
<span className="ctrl-rail-tbl-label">{hasKoLabel ? rawLabel : name}</span>
{hasKoLabel && <span className="ctrl-rail-tbl-sub">{name}</span>}
</span>
</div>
);
};
return (
<div className="ctrl-ide-leftrail">
{/* 검색 */}
<div className="ctrl-rail-search">
<Search size={11} />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="테이블 / 노드 검색…"
/>
</div>
{/* ① 카드 */}
<RailSection ic={<LayoutDashboard size={11} />} title="이 대시보드의 카드" count={cards.length}>
<div className="ctrl-rail-cards">
{cards.map((c) => {
const id = c.card_id ?? c.CARD_ID ?? c.id;
const title = c.title ?? c.TITLE ?? '카드';
const table = c.primary_table ?? c.PRIMARY_TABLE ?? '';
const sel = id === selectedCardId;
return (
<button
key={id}
type="button"
className={`ctrl-rail-card${sel ? ' on' : ''}`}
onClick={() => setSelectedCardId(id)}
>
<div className="ctrl-rail-card-ico">
<Database size={12} />
</div>
<div className="ctrl-rail-card-body">
<div className="ctrl-rail-card-title">{title}</div>
{table && <div className="ctrl-rail-card-tbl">{table}</div>}
</div>
{sel && <ChevronRight size={10} className="ctrl-rail-card-chev" />}
</button>
);
})}
{cards.length === 0 && <div className="ctrl-rail-empty"> </div>}
</div>
</RailSection>
{/* ② DB 테이블 (edit 모드일 때만) — 한글 라벨 가나다순 우선, 이모티콘 없음 */}
{mode === 'edit' && (
<RailSection ic={<Database size={11} />} title="DB 테이블" count={sortedTables.length} expand>
<div className="ctrl-rail-tbls">
{sortedTables.map((t) => renderTableItem(t))}
{sortedTables.length === 0 && query && (
<div className="ctrl-rail-empty"> </div>
)}
{sortedTables.length === 0 && !query && tables.length === 0 && (
<div className="ctrl-rail-empty"> </div>
)}
</div>
</RailSection>
)}
{/* ③ 노드 팔레트 (edit 모드만) */}
{mode === 'edit' && (
<RailSection ic={<Boxes size={11} />} title="노드 팔레트" count={Object.keys(CTRL_NODE_TYPES).length} expand>
<div className="ctrl-rail-nodes">
{NODE_CATEGORIES.map((cat) => {
const items = nodeEntries.filter(([, def]) => ctrlCatToV3(def.cat) === cat.id);
if (items.length === 0) return null;
return (
<div key={cat.id} className="ctrl-rail-nodecat">
<div className="ctrl-rail-cat-label" style={{ color: `rgb(${cat.rgb})` }}>
<span className="ctrl-rail-cat-dot" style={{ background: `rgb(${cat.rgb})` }} />
<span>{cat.label}</span>
<span className="ctrl-rail-cat-count">{items.length}</span>
</div>
<div className="ctrl-rail-nodes-grid">
{items.map(([type, def]) => {
const Ic = getNodeIcon(type);
return (
<div
key={type}
className="ctrl-rail-node"
draggable
onDragStart={(e) => onDragNode(e, type)}
title={`${def.label} (${type}) — 캔버스로 드래그`}
>
<Ic size={10} style={{ color: `rgb(${def.rgb})`, flexShrink: 0 }} />
<span className="ctrl-rail-node-label">{def.label}</span>
</div>
);
})}
</div>
</div>
);
})}
{nodeEntries.length === 0 && (
<div className="ctrl-rail-empty"> </div>
)}
</div>
</RailSection>
)}
{mode !== 'edit' && (
<div className="ctrl-rail-hint">
<Boxes size={14} />
<span>EDIT DB / </span>
</div>
)}
</div>
);
}
function RailSection({
ic, title, count, expand, children,
}: {
ic: React.ReactNode;
title: string;
count: number;
expand?: boolean;
children: React.ReactNode;
}) {
return (
<div className={`ctrl-rail-sec${expand ? ' expand' : ''}`}>
<div className="ctrl-rail-sec-head">
{ic}
<span className="ctrl-rail-sec-title">{title}</span>
<span className="ctrl-rail-sec-count">{count}</span>
</div>
<div className="ctrl-rail-sec-body">{children}</div>
</div>
);
}
@@ -0,0 +1,182 @@
'use client';
/**
* PanZoomStage + +
* v3 rich-ui.jsx PanZoomStage
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ZoomIn, ZoomOut, Maximize, Hand } from 'lucide-react';
interface PanZoomStageProps {
width: number;
height: number;
initialFit?: boolean;
minScale?: number;
maxScale?: number;
onCanvasDrop?: (drop: { x: number; y: number; type: string }) => void;
children: ReactNode | ((ctx: { scale: number }) => ReactNode);
}
export function PanZoomStage({
width, height,
initialFit = true,
minScale = 0.25, maxScale = 1.6,
onCanvasDrop,
children,
}: PanZoomStageProps) {
const ref = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
const [dragging, setDragging] = useState(false);
const [dropHover, setDropHover] = useState(false);
const dragStart = useRef<{ x: number; y: number } | null>(null);
const panStart = useRef<{ x: number; y: number } | null>(null);
const scaleRef = useRef(1);
const panRef = useRef({ x: 0, y: 0 });
useEffect(() => { scaleRef.current = scale; }, [scale]);
useEffect(() => { panRef.current = pan; }, [pan]);
// initial fit + recompute on resize
useEffect(() => {
if (!ref.current) return;
const fit = () => {
const el = ref.current; if (!el) return;
const pw = el.clientWidth, ph = el.clientHeight;
if (initialFit) {
const s = Math.min(pw / width, ph / height, 1);
setScale(s);
setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 });
} else {
setPan({ x: (pw - width) / 2, y: (ph - height) / 2 });
}
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(ref.current);
return () => ro.disconnect();
}, [width, height, initialFit]);
const screenToCanvas = (clientX: number, clientY: number) => {
if (!ref.current) return { x: 0, y: 0 };
const rect = ref.current.getBoundingClientRect();
return {
x: (clientX - rect.left - panRef.current.x) / scaleRef.current,
y: (clientY - rect.top - panRef.current.y) / scaleRef.current,
};
};
const onMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-pz-node], button, input, select, textarea, a')) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
panStart.current = { ...pan };
e.preventDefault();
};
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => {
if (!dragStart.current || !panStart.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
};
const onUp = () => setDragging(false);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [dragging]);
const onWheel = (e: React.WheelEvent) => {
if (!ref.current) return;
e.preventDefault();
const delta = -e.deltaY * 0.0015;
const next = Math.max(minScale, Math.min(maxScale, scale * (1 + delta)));
const rect = ref.current.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const k = next / scale;
setPan({ x: cx - (cx - pan.x) * k, y: cy - (cy - pan.y) * k });
setScale(next);
};
const onDragOver = (e: React.DragEvent) => {
if (!onCanvasDrop) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setDropHover(true);
};
const onDragLeave = () => setDropHover(false);
const onDrop = (e: React.DragEvent) => {
if (!onCanvasDrop) return;
e.preventDefault();
setDropHover(false);
const type = e.dataTransfer.getData('application/x-ctrl-node-type')
|| e.dataTransfer.getData('text/plain');
if (!type) return;
const { x, y } = screenToCanvas(e.clientX, e.clientY);
onCanvasDrop({ x, y, type });
};
const zoomIn = () => setScale((s) => Math.min(maxScale, s * 1.15));
const zoomOut = () => setScale((s) => Math.max(minScale, s / 1.15));
const reset = () => {
if (!ref.current) return;
const pw = ref.current.clientWidth, ph = ref.current.clientHeight;
const s = Math.min(pw / width, ph / height, 1);
setScale(s);
setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 });
};
const childOut = typeof children === 'function' ? children({ scale }) : children;
return (
<>
<div
ref={ref}
onMouseDown={onMouseDown}
onWheel={onWheel}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className="ctrl-pz-stage"
style={{
cursor: dragging ? 'grabbing' : 'grab',
background: dropHover ? 'rgba(var(--v5-cyan-rgb), .04)' : 'transparent',
boxShadow: dropHover ? 'inset 0 0 0 2px rgba(var(--v5-cyan-rgb), .4)' : 'none',
userSelect: dragging ? 'none' : 'auto',
}}
>
<div
ref={innerRef}
style={{
position: 'absolute', left: 0, top: 0,
width, height,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: '0 0',
}}
>
{childOut}
</div>
</div>
<div className="ctrl-pz-zoom">
<button onClick={zoomOut} title="축소"><ZoomOut size={11} /></button>
<button onClick={reset} title="맞춤"><Maximize size={11} /></button>
<button onClick={zoomIn} title="확대"><ZoomIn size={11} /></button>
<span className="ctrl-pz-pct">{Math.round(scale * 100)}%</span>
</div>
<div className="ctrl-pz-hint">
<Hand size={10} />
{onCanvasDrop ? '드래그로 이동 · 휠로 확대/축소 · 팔레트 드롭으로 노드 추가' : '드래그로 이동 · 휠로 확대/축소'}
</div>
</>
);
}
@@ -0,0 +1,276 @@
'use client';
import { useEffect, useState } from 'react';
import { Info, Database, ScrollText, Trash2, Activity, Wrench } from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { NODE_TYPE_SCHEMAS, type NodeFieldSchema } from '../schemas';
import { getNodeStats, listNodeComments, type NodeStats, type NodeComment } from '@/lib/api/control';
interface RightRailProps {
selectedCard: Record<string, any>;
}
export function RightRail({ selectedCard }: RightRailProps) {
const configNodeId = useControlMode((s) => s.configNodeId);
const ruleNodes = useControlMode((s) => s.ruleNodes);
const updateRuleNode = useControlMode((s) => s.updateRuleNode);
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
const setConfigNodeId = useControlMode((s) => s.setConfigNodeId);
const selectedNode = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
return (
<div className="ctrl-ide-rightrail">
{/* 섹션 1: 노드 설정 / 카드 정보 */}
<div className="ctrl-rail-sec">
<div className="ctrl-rail-sec-head">
{selectedNode ? <Wrench size={11} /> : <Info size={11} />}
<span className="ctrl-rail-sec-title">
{selectedNode ? '노드 설정' : '데이터 인스펙터'}
</span>
<span className="ctrl-rail-sec-count">
{selectedNode ? selectedNode.id : '—'}
</span>
</div>
<div className="ctrl-rail-sec-body">
{selectedNode ? (
<NodeInspector
node={selectedNode}
onChange={(patch) => updateRuleNode(selectedNode.id, patch)}
onDelete={() => { removeRuleNode(selectedNode.id); setConfigNodeId(null); }}
/>
) : (
<CardInfo card={selectedCard} />
)}
</div>
</div>
{/* 섹션 2: 실행 상태 (v3 V3LiveItem 4개 미러) — 실 데이터 없으면 '—' fallback */}
<ActivitySection />
</div>
);
}
function ActivitySection() {
// 실 데이터 연결 전: 모든 값 '—' (control.ts 에 getControlActivity API 추가 시 연결)
// TODO: API listControlActivity(cardId) 추가 후 useEffect 로 fetch
const items: Array<{ label: string; value: string; dot?: 'ok' | 'warn' | 'bad' }> = [
{ label: '최근 트리거', value: '—' },
{ label: '오늘 실행', value: '—' },
{ label: '평균 latency', value: '—' },
{ label: '대기 큐', value: '—' },
];
return (
<div className="ctrl-rail-sec">
<div className="ctrl-rail-sec-head">
<Activity size={11} />
<span className="ctrl-rail-sec-title"> </span>
<span className="ctrl-rail-sec-count">live</span>
</div>
<div className="ctrl-rail-sec-body">
<div className="ctrl-activity">
{items.map((it) => (
<div key={it.label} className="ctrl-activity-row">
<span className="ctrl-activity-label">{it.label}</span>
<span className="ctrl-activity-value">
{it.dot && <span className={`ctrl-activity-dot ${it.dot}`} />}
{it.value}
</span>
</div>
))}
</div>
</div>
</div>
);
}
function NodeInspector({
node, onChange, onDelete,
}: {
node: Record<string, any>;
onChange: (patch: Record<string, any>) => void;
onDelete: () => void;
}) {
const schema: NodeFieldSchema[] = NODE_TYPE_SCHEMAS[node.type] ?? [];
const config: Record<string, any> = node.config ?? {};
const def = CTRL_NODE_TYPES[node.type];
const [stats, setStats] = useState<NodeStats | null>(null);
const [comments, setComments] = useState<NodeComment[]>([]);
useEffect(() => {
let alive = true;
getNodeStats(node.id).then((s) => { if (alive) setStats(s); });
listNodeComments(node.id).then((c) => { if (alive) setComments(c); });
return () => { alive = false; };
}, [node.id]);
return (
<>
<div className="ctrl-sec-head">
<span className="ctrl-sec-ico"><Info size={11} /></span>
Inspector
<span className="ctrl-sec-count">{def?.label ?? node.type}</span>
<span className="ctrl-sec-right">
<button
type="button"
className="ctrl-ide-tool ctrl-ide-mini"
onClick={onDelete}
title="노드 삭제"
>
<Trash2 size={11} />
</button>
</span>
</div>
<div className="ctrl-ide-inspector">
{/* node 식별 */}
<div className="ctrl-ide-field ctrl-ide-field-meta">
<div>
<span className="ctrl-ide-field-k"> ID</span>
<code>{node.id}</code>
</div>
{def && (
<div>
<span className="ctrl-ide-field-k"></span>
<span style={{ color: `rgb(${def.rgb})`, fontWeight: 700 }}>
{def.icon} {def.label}
</span>
</div>
)}
</div>
{/* schema 기반 필드 */}
{schema.length === 0 && (
<div className="ctrl-ide-empty"> </div>
)}
{schema.map((f) => (
<div key={f.k} className="ctrl-ide-field">
<label className="ctrl-ide-field-label">
{f.l}
{f.locked && <span className="ctrl-ide-field-locked"> · </span>}
</label>
{f.select ? (
<select
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
>
{f.select.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : f.multiline ? (
<textarea
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
placeholder={f.hint}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
rows={3}
/>
) : (
<input
type="text"
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
placeholder={f.hint}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
/>
)}
{f.hint && !f.multiline && <div className="ctrl-ide-field-hint">{f.hint}</div>}
</div>
))}
{/* 통계 */}
{stats && (
<>
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
<span className="ctrl-sec-ico"><Activity size={11} /></span>
</div>
<div className="ctrl-ide-stats">
<div><span className="ctrl-ide-field-k"></span><code>{stats.runs}</code></div>
<div><span className="ctrl-ide-field-k"> ms</span><code>{stats.lastMs ?? '—'}</code></div>
<div>
<span className="ctrl-ide-field-k"></span>
<span className={`ctrl-validation-dot ${stats.valid ? 'ok' : 'bad'}`} />
</div>
{stats.alert && (
<div className="ctrl-ide-stat-alert">{stats.alert}</div>
)}
</div>
</>
)}
{/* 댓글 */}
{comments.length > 0 && (
<>
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
<span className="ctrl-sec-count">{comments.length}</span>
</div>
<div className="ctrl-ide-comments">
{comments.map((c, i) => (
<div key={i} className="ctrl-ide-comment">
<span
className="ctrl-ide-avatar"
style={{ background: `rgb(${c.color})` }}
title={c.who}
>
{c.short}
</span>
<div>
<div className="ctrl-ide-comment-meta">
<b>{c.who}</b><span> · {c.at}</span>
</div>
<div className="ctrl-ide-comment-text">{c.text}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</>
);
}
function CardInfo({ card }: { card: Record<string, any> }) {
const title = card.title ?? card.TITLE ?? '카드';
const table = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
return (
<>
<div className="ctrl-sec-head">
<span className="ctrl-sec-ico"><Database size={11} /></span>
</div>
<div className="ctrl-ide-card-info">
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k"></span>
<span>{title}</span>
</div>
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k"></span>
<code>{table || '—'}</code>
</div>
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k">ID</span>
<code>{cardId}</code>
</div>
</div>
<div className="ctrl-sec-head" style={{ marginTop: 16 }}>
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
</div>
<div className="ctrl-ide-help">
<p> .</p>
<p> .</p>
<p> <b>READ / EDIT / RUN / HISTORY</b> .</p>
</div>
</>
);
}
@@ -0,0 +1,48 @@
'use client';
/**
* StatusBar v3 V3StatusBar
* 좌측: Workflow icon + + dirty + /
* 중간: 진행 dot (pulse) +
* 우측: 최근
*
* '—' fallback ( mock )
*/
import { Workflow } from 'lucide-react';
import { useControlMode } from '../hooks/useControlMode';
interface StatusBarProps {
selectedCard: Record<string, any>;
}
export function StatusBar({ selectedCard }: StatusBarProps) {
void selectedCard;
const mode = useControlMode((s) => s.mode);
const ruleNodes = useControlMode((s) => s.ruleNodes);
const ruleConnections = useControlMode((s) => s.ruleConnections);
const activeRuleId = useControlMode((s) => s.activeRuleId);
// 룰 이름 — store 에 룰 메타 없으면 '—' (룰 메타 API 연결 후 채워짐)
const ruleName = activeRuleId ?? '—';
return (
<div className="ctrl-ide-statusbar">
<span className="ctrl-status-rule">
<Workflow size={11} style={{ color: 'rgb(var(--v5-primary-rgb))' }} />
{ruleName}
<span className="ctrl-status-ver">v0</span>
</span>
<span><b>NODES</b> <code>{ruleNodes.length}</code></span>
<span><b>EDGES</b> <code>{ruleConnections.length}</code></span>
<span><b>MODE</b> <code>{mode.toUpperCase()}</code></span>
<div style={{ flex: 1 }} />
<span className="ctrl-status-pulse" title="live" />
<span><b> </b> <code></code></span>
<span><b></b> <code>ms</code></span>
</div>
);
}
@@ -0,0 +1,159 @@
'use client';
/**
* V3RuleNode v3 v3-canvas.jsx V3RuleNode
* cat-color stripe + validation dot + comment avatar + cat-chip header + label + summary + stats + ports
*/
import { CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { getNodeIcon } from '../schemas';
import type { NodeStats, NodeComment } from '@/lib/api/control';
interface V3RuleNodeProps {
node: Record<string, any> & { cx: number; cy: number };
scale: number;
selected: boolean;
dim: boolean;
stats?: NodeStats;
comments?: NodeComment[];
onSelect: () => void;
onDrag: (dx: number, dy: number) => void;
onContextMenu: (canvasX: number, canvasY: number) => void;
}
export function V3RuleNode({
node, scale, selected, dim, stats, comments,
onSelect, onDrag, onContextMenu,
}: V3RuleNodeProps) {
const def = CTRL_NODE_TYPES[node.type];
if (!def) return null;
const rgb = def.rgb;
const Ic = getNodeIcon(node.type);
// Pointer Events + setPointerCapture — transform/scale 안에서도 mouse 이벤트 안정적으로 받음
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, input, select, textarea')) return;
e.stopPropagation();
const el = e.currentTarget as HTMLDivElement;
const pointerId = e.pointerId;
try { el.setPointerCapture(pointerId); } catch { /* unsupported */ }
const start = { x: e.clientX, y: e.clientY };
let moved = false;
const onMove = (ev: Event) => {
const pe = ev as PointerEvent;
const dx = (pe.clientX - start.x) / (scale || 1);
const dy = (pe.clientY - start.y) / (scale || 1);
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
moved = true;
onDrag(dx, dy);
start.x = pe.clientX;
start.y = pe.clientY;
};
const onUp = () => {
try { el.releasePointerCapture(pointerId); } catch { /* */ }
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
if (!moved) onSelect();
};
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
};
const onCtx = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(node.cx, node.cy + 70);
};
const lastMs = stats?.lastMs ?? null;
const latColor =
lastMs == null ? undefined :
lastMs < 30 ? 'var(--v5-green)' :
lastMs < 100 ? 'var(--v5-text-sec)' :
'var(--v5-amber)';
const summary = node.summary?.[0]
?? (node.config ? Object.entries(node.config).slice(0, 1).map(([k, v]) => `${k}: ${v}`)[0] : null);
const firstComment = comments?.[0];
return (
<div
data-pz-node="true"
draggable={false}
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
onPointerDown={onPointerDown}
onContextMenu={onCtx}
style={{
left: node.cx, top: node.cy,
borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
boxShadow: selected
? `0 0 0 4px rgba(${rgb}, .14), 0 0 24px rgba(${rgb}, .22)`
: '0 4px 12px -4px rgba(0, 0, 0, .08)',
touchAction: 'none',
}}
>
{/* cat-color stripe */}
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
{/* validation dot */}
{stats && (
<span
className="v3-rule-node-vdot"
style={{
background: stats.valid
? (stats.alert ? 'var(--v5-amber)' : 'var(--v5-green)')
: 'var(--v5-red)',
boxShadow: stats.valid
? (stats.alert ? '0 0 5px var(--v5-amber)' : '0 0 5px var(--v5-green)')
: '0 0 5px var(--v5-red)',
}}
title={stats.alert || (stats.valid ? '정상' : '검증 실패')}
/>
)}
{/* comment avatar */}
{firstComment && (
<span
className="v3-rule-node-comment"
title={firstComment.text}
style={{ background: `rgb(${firstComment.color})` }}
>
{firstComment.short}
</span>
)}
{/* body */}
<div className="v3-rule-node-body">
<div className="v3-rule-node-cat">
<div
className="v3-rule-node-cat-ico"
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
>
<Ic size={11} />
</div>
<span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
{def.label}
</span>
</div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</div>}
{stats && (
<div className="v3-rule-node-stats">
<span>{stats.runs.toLocaleString()} runs</span>
{lastMs != null && (
<span style={{ fontWeight: 700, color: latColor }}>{lastMs}ms</span>
)}
</div>
)}
</div>
{/* ports */}
<div className="v3-rule-node-port v3-rule-node-port-in"
style={{ borderColor: `rgb(${rgb})` }} />
<div className="v3-rule-node-port v3-rule-node-port-out"
style={{ background: `rgb(${rgb})` }} />
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* schema (Inspector )
* v3 shared.jsx NODE_TYPE_SCHEMAS
*
* (DB ). 16 "필드 정의" .
* ruleNode.config .
*/
export interface NodeFieldSchema {
k: string;
l: string;
v?: string;
mono?: boolean;
select?: string[];
multiline?: boolean;
hint?: string;
locked?: boolean;
}
export const NODE_TYPE_SCHEMAS: Record<string, NodeFieldSchema[]> = {
'timer': [
{ k: 'schedule', l: '스케줄 (cron)', v: '0 0 * * *', mono: true, hint: '매일 자정' },
{ k: 'timezone', l: '타임존', v: 'Asia/Seoul', select: ['Asia/Seoul', 'UTC', 'America/Los_Angeles'] },
{ k: 'max_runs', l: '1회 최대 실행 수', v: '1000', mono: true },
],
'status-change': [
{ k: 'table', l: '대상 테이블', v: '', mono: true, locked: true },
{ k: 'from', l: '이전 상태', v: '', mono: true },
{ k: 'to', l: '변경 상태', v: '', mono: true, hint: '트리거 조건' },
],
'condition': [
{ k: 'expr', l: '조건식', v: '', mono: true, hint: 'JS 표현식 — true/false 반환' },
{ k: 'yes_label', l: 'YES 분기 라벨', v: '예' },
{ k: 'no_label', l: 'NO 분기 라벨', v: '아니오' },
],
'validation': [
{ k: 'rules', l: '검증 룰', v: '', mono: true, multiline: true },
{ k: 'on_fail', l: '실패 시 동작', v: 'abort', select: ['abort', 'skip', 'log'] },
{ k: 'alert_owner', l: '실패 알림 대상', v: '' },
],
'auto-insert': [
{ k: 'target', l: '대상 테이블', v: '', mono: true },
{ k: 'mapping', l: '필드 매핑', v: '', mono: true, multiline: true },
{ k: 'fk_link', l: 'FK 연결 키', v: '', mono: true },
],
'calculation': [
{ k: 'expr', l: '수식', v: '', mono: true },
{ k: 'out_field', l: '결과 필드', v: '', mono: true },
{ k: 'round', l: '소수점', v: '2' },
],
'delete': [
{ k: 'target', l: '대상 테이블', v: '', mono: true },
{ k: 'soft_delete', l: 'Soft delete', v: 'true', select: ['true', 'false'] },
{ k: 'archive_to', l: '보관 테이블', v: '', mono: true },
],
'document': [
{ k: 'template', l: '템플릿', v: '', mono: true },
{ k: 'output', l: '출력 경로', v: '', mono: true },
{ k: 'format', l: '포맷', v: 'pdf', select: ['pdf', 'docx', 'html'] },
],
'approval': [
{ k: 'approver', l: '결재자', v: '' },
{ k: 'sla', l: 'SLA (시간)', v: '4', mono: true },
{ k: 'on_reject', l: '반려 시', v: 'rollback', select: ['rollback', 'manual', 'log'] },
],
'delay': [
{ k: 'duration', l: '대기 시간', v: '30m', mono: true, hint: '예: 30m / 2h / 1d' },
{ k: 'unit', l: '단위', v: 'minute', select: ['second', 'minute', 'hour', 'day'] },
],
'loop': [
{ k: 'source', l: '반복 대상', v: '', mono: true },
{ k: 'max', l: '최대 반복', v: '100', mono: true },
],
'parallel': [
{ k: 'branches', l: '병렬 브랜치 수', v: '2', mono: true },
{ k: 'wait', l: 'join 대기', v: 'all', select: ['all', 'any', 'first'] },
],
'merge': [
{ k: 'strategy', l: '병합 전략', v: 'overwrite', select: ['overwrite', 'keep', 'custom'] },
],
'webhook': [
{ k: 'url', l: 'URL', v: '', mono: true },
{ k: 'method', l: '메서드', v: 'POST', select: ['GET', 'POST', 'PUT', 'DELETE'] },
{ k: 'headers', l: '헤더', v: '', mono: true, multiline: true },
],
'notification': [
{ k: 'channel', l: '채널', v: 'slack', select: ['slack', 'email', 'teams', 'webhook'] },
{ k: 'target', l: '대상', v: '', mono: true },
{ k: 'template', l: '메시지', v: '', mono: true, multiline: true },
],
'log': [
{ k: 'table', l: '대상', v: 'audit_log', mono: true },
{ k: 'level', l: '레벨', v: 'info', select: ['debug', 'info', 'warn', 'error'] },
{ k: 'msg', l: '메시지', v: '', mono: true },
],
};
/** 카테고리 메타 — palette / inspector 색상 매핑 */
export const NODE_CATEGORIES: Array<{
id: 'trigger' | 'cond' | 'action' | 'flow' | 'extern' | 'log';
label: string;
cls: string;
rgb: string;
}> = [
{ id: 'trigger', label: '트리거', cls: 'c-trigger', rgb: '0,206,201' },
{ id: 'cond', label: '조건', cls: 'c-cond', rgb: '253,203,110' },
{ id: 'action', label: '액션', cls: 'c-action', rgb: '108,92,231' },
{ id: 'flow', label: '흐름', cls: 'c-flow', rgb: '253,121,168' },
{ id: 'extern', label: '연동', cls: 'c-extern', rgb: '0,184,148' },
{ id: 'log', label: '기록', cls: 'c-log', rgb: '107,107,118' },
];
/** invyone CTRL_NODE_TYPES 의 cat (한글) → v3 cat (영문) 매핑 */
export function ctrlCatToV3(catKo: string): 'trigger' | 'cond' | 'action' | 'flow' | 'extern' | 'log' {
switch (catKo) {
case '트리거': return 'trigger';
case '조건': return 'cond';
case '액션': return 'action';
case '흐름': return 'flow';
case '연동': return 'extern';
case '기록': return 'log';
default: return 'action';
}
}
import {
Clock4, Activity, GitBranch, ShieldCheck,
FilePlus2, Calculator, Archive, FileText,
Stamp, Timer, Repeat, GitMerge, Combine,
Webhook, BellRing, ScrollText, Circle,
type LucideIcon,
} from 'lucide-react';
/** 노드 타입 → Lucide 아이콘 매핑 (v3 시안 NODE_TYPES.icon 미러) */
const NODE_LUCIDE: Record<string, LucideIcon> = {
'timer': Clock4,
'status-change': Activity,
'condition': GitBranch,
'validation': ShieldCheck,
'auto-insert': FilePlus2,
'calculation': Calculator,
'delete': Archive,
'document': FileText,
'approval': Stamp,
'delay': Timer,
'loop': Repeat,
'parallel': GitMerge,
'merge': Combine,
'webhook': Webhook,
'notification': BellRing,
'log': ScrollText,
};
export function getNodeIcon(nodeType: string): LucideIcon {
return NODE_LUCIDE[nodeType] ?? Circle;
}
+5 -1
View File
@@ -13,6 +13,7 @@
import React from 'react'; import React from 'react';
import type { BlockV2, CanvasV2 } from '@/types/invyone-component'; import type { BlockV2, CanvasV2 } from '@/types/invyone-component';
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry'; import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
import { isTableLikeComponentType } from '@/lib/utils/componentTypeUtils';
// side-effect: 컴포넌트 레지스트리 등록 // side-effect: 컴포넌트 레지스트리 등록
import '@/lib/registry/components'; import '@/lib/registry/components';
import type { TemplateRenderContext, ViewKey } from './TemplateRenderer'; import type { TemplateRenderContext, ViewKey } from './TemplateRenderer';
@@ -68,7 +69,10 @@ export function BlockRenderer({
context.onFormRowChange?.(fieldNameOrPatch); context.onFormRowChange?.(fieldNameOrPatch);
}; };
const def = ComponentRegistry.getComponent(block.componentId); const registryComponentId = isTableLikeComponentType(block.componentId)
? 'table'
: block.componentId;
const def = ComponentRegistry.getComponent(registryComponentId);
if (!def?.component) { if (!def?.component) {
return ( return (
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"> <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
+5 -9
View File
@@ -15,7 +15,7 @@ import { DashboardCanvas } from './DashboardCanvas';
import { TemplateLibraryModal } from './TemplateLibraryModal'; import { TemplateLibraryModal } from './TemplateLibraryModal';
import { CardSettingsPanel } from './CardSettingsPanel'; import { CardSettingsPanel } from './CardSettingsPanel';
import { ControlMode } from '@/components/control/ControlMode'; import { ControlMode } from '@/components/control/ControlMode';
import { ControlPalette } from '@/components/control/ControlPalette'; // ControlPalette 는 ControlMode 의 IDE LeftRail 안에서만 사용됨 (외부 사이드바 교체 폐기)
import { useControlMode } from '@/components/control/hooks/useControlMode'; import { useControlMode } from '@/components/control/hooks/useControlMode';
import { useMenu } from '@/contexts/MenuContext'; import { useMenu } from '@/contexts/MenuContext';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -45,7 +45,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
} = useDashboardStore(); } = useDashboardStore();
const controlActive = useControlMode((s) => s.active); const controlActive = useControlMode((s) => s.active);
const controlMode = useControlMode((s) => s.mode); // controlMode 는 ControlMode 내부에서만 참조 (외부 사이드바 분기 폐기)
const { refreshMenus } = useMenu(); const { refreshMenus } = useMenu();
const isSingleMode = !!singleDashboardId; const isSingleMode = !!singleDashboardId;
@@ -243,13 +243,8 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
return ( return (
<div className="dash-shell"> <div className="dash-shell">
{/* AppLayout . {/* AppLayout .
. */} takeover ControlMode IDE LeftRail (v3 V3Takeover) X */}
{controlActive && controlMode === 'edit' ? ( {!isSingleMode && !controlActive ? (
<div className="dash-side">
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}> </div>
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
</div>
) : !isSingleMode ? (
<DashboardSidebar <DashboardSidebar
onAddDashboard={handleAddDashboard} onAddDashboard={handleAddDashboard}
onRenameDashboard={handleRenameDashboard} onRenameDashboard={handleRenameDashboard}
@@ -257,6 +252,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
onSwitchDashboard={handleSwitchDashboard} onSwitchDashboard={handleSwitchDashboard}
/> />
) : null} ) : null}
{/* 제어 모드 ON 이지만 카드 미선택 상태에서는 사이드바 자체를 숨김 (IDE 가 화면 takeover 할 자리 확보) */}
<div className="dash-content"> <div className="dash-content">
{activeDashboardId ? ( {activeDashboardId ? (
<> <>
+7
View File
@@ -73,6 +73,9 @@ import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
import { getIconComponent } from "@/components/admin/MenuIconPicker"; import { getIconComponent } from "@/components/admin/MenuIconPicker";
import { animatedThemeChange } from "@/lib/themeTransition"; import { animatedThemeChange } from "@/lib/themeTransition";
// MANAGEMENT_ONLY_MENU_URLS — DB 컬럼 IS_SOLUTION_ONLY 로 이전 (PR #D).
// 백엔드 /api/admin/user-menus 가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 Set 불필요.
interface ExtendedUserInfo { interface ExtendedUserInfo {
user_id: string; user_id: string;
user_name: string; user_name: string;
@@ -286,6 +289,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>(""); const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
// isMgmtSite / MANAGEMENT_ONLY_MENU_URLS — DB IS_SOLUTION_ONLY 컬럼으로 이전 (PR #D).
// 백엔드가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 상태 불필요.
const tweaksAnchorRef = useRef<HTMLButtonElement>(null); const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme: rawSetTheme } = useTheme(); const { theme, setTheme: rawSetTheme } = useTheme();
@@ -924,6 +930,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
); );
} }
// 솔루션 전용 메뉴 필터는 백엔드 IS_SOLUTION_ONLY 컬럼 + Host 헤더 기반 SQL 필터로 위임 (PR #D).
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : []; const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장. // 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
@@ -529,9 +529,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
); );
} }
// 탭 컴포넌트 처리 (v1, v2 모두 지원) // 탭 컴포넌트 처리 (v1, v2 + canonical container 모두 지원)
const componentType = (comp as any).componentType || (comp as any).componentId; const componentType = (comp as any).componentType || (comp as any).componentId;
if (comp.type === "tabs" || (comp.type === "component" && (componentType === "tabs-widget" || componentType === "v2-tabs-widget"))) { const isCanonicalTabsContainer =
componentType === "container" &&
(((comp as any).component_config?.containerType ?? (comp as any).componentConfig?.containerType ?? "section") === "tabs");
if (
comp.type === "tabs" ||
(comp.type === "component" &&
(componentType === "tabs-widget" || componentType === "v2-tabs-widget" || isCanonicalTabsContainer))
) {
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
// componentConfig에서 탭 정보 추출 // componentConfig에서 탭 정보 추출
+37 -11
View File
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react"; import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11) // 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11)
// shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화. // shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화.
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; // import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -3660,8 +3661,11 @@ export default function InvyoneStudio({
for (const comp of layout.components) { for (const comp of layout.components) {
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type; const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
const isCanonicalTabs =
compType === "container" && (compConfig.containerType ?? "section") === "tabs";
if (compType === "tabs-widget" || compType === "v2-tabs-widget") { if (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabs) {
const tabs = compConfig.tabs || []; const tabs = compConfig.tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -3680,7 +3684,10 @@ export default function InvyoneStudio({
const panelComps = compConfig[side]?.components || []; const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) { for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type; const pct = pc.componentType || pc.overrides?.type;
if (pct === "tabs-widget" || pct === "v2-tabs-widget") { const pcConfig = pc.componentConfig || pc.overrides || {};
const pcIsCanonicalTabs =
pct === "container" && (pcConfig.containerType ?? "section") === "tabs";
if (pct === "tabs-widget" || pct === "v2-tabs-widget" || pcIsCanonicalTabs) {
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || []; const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4060,14 +4067,14 @@ export default function InvyoneStudio({
// 컴포넌트별 gridColumns 설정 및 크기 계산 // 컴포넌트별 gridColumns 설정 및 크기 계산
let componentSize = component.defaultSize; let componentSize = component.defaultSize;
const isTableList = component.id === "table-list"; const isTableLike = isTableLikeComponentType(component.id);
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정 // 컴포넌트 타입별 기본 그리드 컬럼 수 설정
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수 const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
let gridColumns = 1; // 기본값 let gridColumns = 1; // 기본값
// 특수 컴포넌트 // 특수 컴포넌트
if (isTableList) { if (isTableLike) {
gridColumns = currentGridColumns; // 테이블은 전체 너비 gridColumns = currentGridColumns; // 테이블은 전체 너비
} else { } else {
// 웹타입별 적절한 그리드 컬럼 수 설정 // 웹타입별 적절한 그리드 컬럼 수 설정
@@ -4095,7 +4102,11 @@ export default function InvyoneStudio({
"divider-basic": 1, // 구분선 (100%) "divider-basic": 1, // 구분선 (100%)
"divider-line": 1, // 구분선 (100%) "divider-line": 1, // 구분선 (100%)
"accordion-basic": 1, // 아코디언 (100%) "accordion-basic": 1, // 아코디언 (100%)
"table-list": 1, // 테이블 리스트 (100%) "table": 1, // canonical 테이블 (100%)
"table-list": 1, // legacy 테이블 리스트 (100%)
"v2-table-list": 1, // hidden legacy 테이블 리스트 (100%)
"data-table": 1, // 데이터 테이블 (100%)
"datatable": 1, // 데이터 테이블 (100%)
"image-display": 4 / 12, // 이미지 표시 (33%) "image-display": 4 / 12, // 이미지 표시 (33%)
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
"flow-widget": 1, // 플로우 위젯 (100%) "flow-widget": 1, // 플로우 위젯 (100%)
@@ -4398,8 +4409,11 @@ export default function InvyoneStudio({
for (const comp of layout.components) { for (const comp of layout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
const isCanonicalTabs =
ct === "container" && (compConfig.containerType ?? "section") === "tabs";
if (ct === "tabs-widget" || ct === "v2-tabs-widget") { if (ct === "tabs-widget" || ct === "v2-tabs-widget" || isCanonicalTabs) {
const tabs = compConfig.tabs || []; const tabs = compConfig.tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4418,7 +4432,10 @@ export default function InvyoneStudio({
const panelComps = compConfig[side]?.components || []; const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) { for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type; const pct = pc.componentType || pc.overrides?.type;
if (pct === "tabs-widget" || pct === "v2-tabs-widget") { const pcConfig = pc.componentConfig || pc.overrides || {};
const pcIsCanonicalTabs =
pct === "container" && (pcConfig.containerType ?? "section") === "tabs";
if (pct === "tabs-widget" || pct === "v2-tabs-widget" || pcIsCanonicalTabs) {
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || []; const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4618,7 +4635,11 @@ export default function InvyoneStudio({
} }
const compType = (targetComponent as any)?.componentType; const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { const compConfigForCheck = (targetComponent as any)?.componentConfig || {};
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
const isCanonicalTabs =
compType === "container" && (compConfigForCheck.containerType ?? "section") === "tabs";
if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabs)) {
const currentConfig = (targetComponent as any).componentConfig || {}; const currentConfig = (targetComponent as any).componentConfig || {};
const tabs = currentConfig.tabs || []; const tabs = currentConfig.tabs || [];
@@ -5508,11 +5529,16 @@ export default function InvyoneStudio({
} }
const compType = (targetComponent as any)?.componentType; const compType = (targetComponent as any)?.componentType;
const compConfigForSelfDropCheck = (targetComponent as any)?.componentConfig || {};
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
const isCanonicalTabsForSelfDrop =
compType === "container" &&
(compConfigForSelfDropCheck.containerType ?? "section") === "tabs";
// 자기 자신을 자신에게 드롭하는 것 방지 // 자기 자신을 자신에게 드롭하는 것 방지
if ( if (
targetComponent && targetComponent &&
(compType === "tabs-widget" || compType === "v2-tabs-widget") && (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabsForSelfDrop) &&
dragState.draggedComponent !== containerId dragState.draggedComponent !== containerId
) { ) {
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
@@ -7532,7 +7558,7 @@ export default function InvyoneStudio({
for (const comp of components) { for (const comp of components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (ct === "tabs-widget" || ct === "v2-tabs-widget") { if (ct === "tabs-widget" || ct === "v2-tabs-widget" || (ct === "container" && (cfg.containerType ?? "section") === "tabs")) {
for (const tab of (cfg.tabs || [])) { for (const tab of (cfg.tabs || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId); const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id }; if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
@@ -7627,7 +7653,7 @@ export default function InvyoneStudio({
for (const comp of prevLayout.components) { for (const comp of prevLayout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (ct === "tabs-widget" || ct === "v2-tabs-widget") { if (ct === "tabs-widget" || ct === "v2-tabs-widget" || (ct === "container" && (cfg.containerType ?? "section") === "tabs")) {
for (const tab of (cfg.tabs || [])) { for (const tab of (cfg.tabs || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId); const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id }; if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
@@ -671,11 +671,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
); );
})()} })()}
{/* 탭 컴포넌트 타입 */} {/* 탭 컴포넌트 타입 (legacy tabs-widget/v2-tabs-widget + canonical container.containerType=tabs) */}
{(type === "tabs" || {(type === "tabs" ||
(type === "component" && (type === "component" &&
((component as any).componentType === "tabs-widget" || (((component as any).componentType ?? (component as any).component_type) === "tabs-widget" ||
(component as any).componentId === "tabs-widget"))) && ((component as any).componentType ?? (component as any).component_type) === "v2-tabs-widget" ||
((component as any).componentId ?? (component as any).component_id) === "tabs-widget" ||
((component as any).componentId ?? (component as any).component_id) === "v2-tabs-widget" ||
(((component as any).componentType ?? (component as any).component_type) === "container" &&
((((component as any).componentConfig ?? (component as any).component_config)?.containerType ?? "section") ===
"tabs"))))) &&
(() => { (() => {
console.log("🎯 탭 컴포넌트 조건 충족:", { console.log("🎯 탭 컴포넌트 조건 충족:", {
type, type,
@@ -24,6 +24,7 @@ import {
subscribeDom as canvasSplitSubscribeDom, subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore"; } from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 컴포넌트 렌더러들 자동 등록 // 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components"; import "@/lib/registry/components";
@@ -360,32 +361,44 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
return `${actualHeight}px`; return `${actualHeight}px`;
} }
const sizingType =
(component as any).componentType ||
(component as any).component_type ||
(component.componentConfig as any)?.type ||
(component as any).widgetType ||
(component as any).widget_type ||
type ||
"";
// 런타임 모드에서 컴포넌트 타입별 높이 처리 // 런타임 모드에서 컴포넌트 타입별 높이 처리
if (!isDesignMode) { if (!isDesignMode) {
const compType = (component as any).componentType || component.componentConfig?.type || "";
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
const fillParentTypes = [ // ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' /
"table-list", "v2-table-list", // 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록.
const fillParentExtraTypes = [
"container",
"grouped-table", "card-list",
"split-panel-layout", "split-panel-layout2", "split-panel-layout", "split-panel-layout2",
"v2-split-panel-layout", "screen-split-panel", "v2-split-panel-layout", "screen-split-panel",
"v2-tab-container", "tab-container", "v2-tab-container", "tab-container",
"tabs-widget", "v2-tabs-widget", "tabs-widget", "v2-tabs-widget",
]; ];
if (fillParentTypes.some(t => compType === t)) { if (isTableLikeComponentType(sizingType) || fillParentExtraTypes.includes(sizingType)) {
return "100%"; return "100%";
} }
const autoHeightTypes = [ const autoHeightTypes = [
"table-search-widget", "v2-table-search-widget", "table-search-widget", "v2-table-search-widget",
"flow-widget", "flow-widget",
]; ];
if (autoHeightTypes.some(t => compType === t || compType.includes(t))) { if (autoHeightTypes.some(t => sizingType === t || sizingType.includes(t))) {
return "auto"; return "auto";
} }
} }
// 1순위: size.height가 있으면 우선 사용 // 1순위: size.height가 있으면 우선 사용
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장)
if (size?.height && size.height > 0) { if (size?.height && size.height > 0) {
if (component.componentConfig?.type === "table-list") { if (isTableLikeComponentType(sizingType)) {
return `${Math.max(size.height, 200)}px`; return `${Math.max(size.height, 200)}px`;
} }
return `${size.height}px`; return `${size.height}px`;
@@ -396,8 +409,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height; return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
} }
// 3순위: 기본값 // 3순위: 기본값 (table-like 는 200px 최소 보장)
if (component.componentConfig?.type === "table-list") { if (isTableLikeComponentType(sizingType)) {
return "200px"; return "200px";
} }
+6 -3
View File
@@ -13,6 +13,7 @@ import {
Link2, Link2,
} from "lucide-react"; } from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 글로우 펄스 애니메이션 CSS 주입 // 글로우 펄스 애니메이션 CSS 주입
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
@@ -224,10 +225,12 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
}; };
// ========== 컴포넌트 종류별 미니어처 색상 ========== // ========== 컴포넌트 종류별 미니어처 색상 ==========
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등) // componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / legacy 'table-list' /
// hidden 'v2-table-list' / 'button-primary' 등)
const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"];
const getComponentColor = (componentKind: string) => { const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련 // 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등)
if (componentKind === "table-list" || componentKind === "data-grid") { if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
return "bg-primary/20 border-primary/40"; return "bg-primary/20 border-primary/40";
} }
// 검색 필터 // 검색 필터
@@ -28,6 +28,17 @@ import { apiClient } from "@/lib/api/client";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval"; import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types"; import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types";
import { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
const isDataTransferComponentType = (typeValue: unknown): boolean => {
if (isTableLikeComponentType(typeValue)) return true;
if (typeof typeValue !== "string") return false;
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
};
/** 액션 탭: 액션 유형별 상세 설정 (모달/이동/엑셀/결재/이벤트 등) */ /** 액션 탭: 액션 유형별 상세 설정 (모달/이동/엑셀/결재/이벤트 등) */
export const ActionTab: React.FC<ButtonTabProps> = ({ export const ActionTab: React.FC<ButtonTabProps> = ({
@@ -344,7 +355,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
// 1. 소스 테이블 감지 (현재 화면) // 1. 소스 테이블 감지 (현재 화면)
let sourceTableName: string | null = currentTableName || null; let sourceTableName: string | null = currentTableName || null;
// allComponents에서 분할패널/테이블리스트/통합목록 감지 // allComponents에서 분할패널/테이블리스트(canonical+legacy+v2)/통합목록 감지
for (const comp of allComponents) { for (const comp of allComponents) {
const compType = comp.component_type || (comp as any).component_config?.type; const compType = comp.component_type || (comp as any).component_config?.type;
const compConfig = (comp as any).component_config || {}; const compConfig = (comp as any).component_config || {};
@@ -353,8 +364,8 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
sourceTableName = compConfig.leftPanel?.table_name || compConfig.table_name || null; sourceTableName = compConfig.leftPanel?.table_name || compConfig.table_name || null;
if (sourceTableName) break; if (sourceTableName) break;
} }
if (compType === "table-list") { if (isTableLikeComponent(comp)) {
sourceTableName = compConfig.table_name || compConfig.selectedTable || null; sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
if (sourceTableName) break; if (sourceTableName) break;
} }
if (compType === "v2-list") { if (compType === "v2-list") {
@@ -518,11 +529,11 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
} }
} }
// 테이블 리스트 타입 // 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두)
if (compType === "table-list") { if (isTableLikeComponent(comp)) {
sourceTableName = compConfig?.table_name; sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
if (sourceTableName) { if (sourceTableName) {
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`); console.log(`✅ [openModalWithData] table-like 컴포넌트에서 소스 테이블 감지: ${sourceTableName}`);
break; break;
} }
} }
@@ -2892,9 +2903,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}) })
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
@@ -2916,9 +2925,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -2989,9 +2996,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = isDataTransferComponentType(type);
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}) })
.map((comp: any) => { .map((comp: any) => {
@@ -3014,9 +3019,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => const isReceivable = isDataTransferComponentType(type);
type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -4430,13 +4433,8 @@ const ExcelUploadConfigSection: React.FC<{
const compId = comp.componentId || comp.componentType; const compId = comp.componentId || comp.componentType;
const compConfig = comp.componentConfig || comp.config || comp; const compConfig = comp.componentConfig || comp.config || comp;
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기 // 테이블 패널이나 데이터 테이블에서 테이블명 찾기 (canonical/legacy/v2 모두)
if ( if (compId === "table-panel" || compId === "simple-table" || isTableLikeComponentType(compId)) {
compId === "table-panel" ||
compId === "data-table" ||
compId === "table-list" ||
compId === "simple-table"
) {
const tableName = compConfig?.table_name || compConfig?.table; const tableName = compConfig?.table_name || compConfig?.table;
if (tableName) return tableName; if (tableName) return tableName;
} }
@@ -12,6 +12,7 @@ import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
export interface DataTabProps { export interface DataTabProps {
config: any; config: any;
@@ -35,6 +36,16 @@ export interface DataTabProps {
>; >;
} }
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
const isDataTransferComponentType = (typeValue: unknown): boolean => {
if (isTableLikeComponentType(typeValue)) return true;
if (typeof typeValue !== "string") return false;
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
};
export const DataTab: React.FC<DataTabProps> = ({ export const DataTab: React.FC<DataTabProps> = ({
config, config,
onChange, onChange,
@@ -106,9 +117,7 @@ export const DataTab: React.FC<DataTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}) })
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
@@ -130,9 +139,7 @@ export const DataTab: React.FC<DataTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -198,9 +205,7 @@ export const DataTab: React.FC<DataTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = isDataTransferComponentType(type);
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}) })
.map((comp: any) => { .map((comp: any) => {
@@ -223,9 +228,7 @@ export const DataTab: React.FC<DataTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = isDataTransferComponentType(type);
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -147,11 +147,14 @@ interface MultilangSettingsModalProps {
} }
// 타입별 아이콘 매핑 // 타입별 아이콘 매핑
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘.
const getTypeIcon = (type: string) => { const getTypeIcon = (type: string) => {
switch (type) { switch (type) {
case "button": case "button":
return <MousePointer className="h-4 w-4" />; return <MousePointer className="h-4 w-4" />;
case "table":
case "table-list": case "table-list":
case "v2-table-list":
return <Table2 className="h-4 w-4" />; return <Table2 className="h-4 w-4" />;
case "split-panel-layout": case "split-panel-layout":
return <LayoutPanelLeft className="h-4 w-4" />; return <LayoutPanelLeft className="h-4 w-4" />;
@@ -192,8 +195,11 @@ const getTypeLabel = (type: string) => {
}; };
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등) // 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등)
// canonical table 및 hidden legacy v2-table-list 도 모두 non-input 으로 분류.
const NON_INPUT_COMPONENT_TYPES = new Set([ const NON_INPUT_COMPONENT_TYPES = new Set([
"table",
"table-list", "table-list",
"v2-table-list",
"split-panel-layout", "split-panel-layout",
"tab-panel", "tab-panel",
"container", "container",
@@ -205,9 +211,35 @@ const NON_INPUT_COMPONENT_TYPES = new Set([
"modal", "modal",
"drawer", "drawer",
"form-layout", "form-layout",
// canonical stats + 옛 저장 화면 호환 (aggregation-widget / v2-aggregation-widget / v2-status-count)
"stats",
"aggregation-widget", "aggregation-widget",
"v2-aggregation-widget",
"v2-status-count",
]); ]);
/**
* canonical stats + stats ID (private helper).
*
* i18n / raw layout JSON compType ID
* . canonical `"stats"` .
*/
const isStatsLikeComponentType = (compType: string | undefined | null): boolean => {
if (!compType) return false;
return (
compType === "stats" ||
compType === "aggregation-widget" ||
compType === "v2-aggregation-widget" ||
compType === "v2-status-count"
);
};
const getStatsItemLabel = (item: any): string | undefined => {
if (!item) return undefined;
const v = item.label ?? item.columnLabel;
return typeof v === "string" && v.length > 0 ? v : undefined;
};
// 컴포넌트가 입력 폼인지 확인 // 컴포넌트가 입력 폼인지 확인
const isInputComponent = (comp: any): boolean => { const isInputComponent = (comp: any): boolean => {
const compType = comp.componentType || comp.type; const compType = comp.componentType || comp.type;
@@ -727,13 +759,14 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
}); });
} }
// 11. 위젯 (aggregation-widget) 항목 라벨 // 11. 카드 (canonical `stats` + legacy aggregation-widget 호환) 항목 라벨
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) { if (isStatsLikeComponentType(compType) && config?.items && Array.isArray(config.items)) {
config.items.forEach((item: any, index: number) => { config.items.forEach((item: any, index: number) => {
if (item.columnLabel && typeof item.columnLabel === "string") { const itemLabel = getStatsItemLabel(item);
if (itemLabel) {
addLabel( addLabel(
`${comp.id}_agg_${item.id || index}`, `${comp.id}_stats_${item.id || index}`,
item.columnLabel, itemLabel,
"label", "label",
compType, compType,
compLabel, compLabel,
@@ -42,7 +42,9 @@ export function ComponentsPanel({
// 레지스트리에서 모든 컴포넌트 조회 // 레지스트리에서 모든 컴포넌트 조회
const allComponents = useMemo(() => { const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents(); const components = ComponentRegistry.getAllComponents();
// v2-table-list가 자동 등록되므로 수동 추가 불필요 // ★ 새 생성 경로는 canonical 'table' (displayMode='table').
// v2-table-list 는 옛 저장 화면 호환 hard blocker 로 자동 등록되지만
// 팔레트에는 hidden 처리한다 (아래 hiddenComponents 참고).
return components; return components;
}, []); }, []);
@@ -134,8 +136,8 @@ export function ComponentsPanel({
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) ===== // ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
"button-primary", // → v2-button-primary "button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout "split-panel-layout", // → v2-split-panel-layout
"aggregation-widget", // → v2-aggregation-widget // aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
"table-list", // → v2-table-list "table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
"text-display", // → v2-text-display "text-display", // → v2-text-display
"divider-line", // → v2-divider-line "divider-line", // → v2-divider-line
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider` // ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
@@ -162,9 +164,10 @@ export function ComponentsPanel({
// radio-basic, toggle-switch (Phase F.1) // radio-basic, toggle-switch (Phase F.1)
// image-widget, entity-search-input, autocomplete-search-input, file-upload (일부) // image-widget, entity-search-input, autocomplete-search-input, file-upload (일부)
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats` // ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
"v2-aggregation-widget", // → stats // v2-aggregation-widget / v2-status-count: 폴더/Renderer 삭제 (2026-05-19).
"v2-status-count", // → stats // ComponentRegistry 에 없음 — hidden list 에 둘 필요 없음. 옛 저장 화면은
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김 // DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `stats` 라우팅.
// card-display 는 기존 상단에서 이미 숨김
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정. // form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지) "field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table` // ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
@@ -173,26 +176,21 @@ export function ComponentsPanel({
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table, // table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김 // simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container` // ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
"v2-tabs-widget", // → container (containerType='tabs') // v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
"v2-section-card", // → container (containerType='section', sectionVariant='card') // 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요.
"v2-section-paper", // → container (containerType='section', sectionVariant='paper') // 옛 저장 화면은 DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `container` 라우팅.
"v2-repeat-container", // → container (containerType='repeater') "v2-repeat-container", // → container (containerType='repeater')
"v2-repeater", // → container (containerType='repeater') "v2-repeater", // → container (containerType='repeater')
// accordion-basic, conditional-container, section-card, section-paper, // accordion-basic, conditional-container, repeat-container, repeat-screen-modal,
// tabs, repeat-container, repeat-screen-modal, repeater-field-group, // repeater-field-group, screen-split-panel 는 기존 상단에서 이미 숨김
// screen-split-panel 는 기존 상단에서 이미 숨김
// numbering-rule: 폐기 (2026-05-11) // numbering-rule: 폐기 (2026-05-11)
"split-panel-layout2", // → table (displayMode='split') Phase E 통합 "split-panel-layout2", // → table (displayMode='split') Phase E 통합
"section-paper", // → v2-section-paper
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector "location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure "rack-structure", // → v2-rack-structure
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container "repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal "repeat-screen-modal", // → v2-repeat-screen-modal
"table-search-widget", // → v2-table-search-widget "table-search-widget", // → v2-table-search-widget
"tabs", // → v2-tabs
"tabs-widget", // → v2-tabs-widget
]; ];
return { return {
@@ -20,7 +20,7 @@ import { LayoutComponent } from "@/types/layout";
// 레거시 ButtonConfigPanel 제거됨 // 레거시 ButtonConfigPanel 제거됨
import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping"; import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel"; import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/v2-components"; import { ConditionalConfig } from "@/types/v2-components";
@@ -871,7 +871,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 // 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.component_config?.type || selectedComponent.component_config?.id; const componentId = selectedComponent.component_config?.type || selectedComponent.component_config?.id;
if (componentId) { if (componentId) {
const definition = ComponentRegistry.getComponent(componentId); const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
if (definition?.config_panel) { if (definition?.config_panel) {
const ConfigPanelComponent = definition.config_panel; const ConfigPanelComponent = definition.config_panel;
const currentConfig = selectedComponent.component_config || {}; const currentConfig = selectedComponent.component_config || {};
@@ -32,7 +32,7 @@ import DataTableConfigPanel from "./DataTableConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
import { import {
BaseInputType, BaseInputType,
BASE_INPUT_TYPE_OPTIONS, BASE_INPUT_TYPE_OPTIONS,
@@ -266,7 +266,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
} }
if (componentId) { if (componentId) {
const definition = ComponentRegistry.getComponent(componentId); const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨. // ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크. // 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
@@ -767,7 +768,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인 // 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨. // ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크. // 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
const definition = ComponentRegistry.getComponent(componentId); const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
const configPanelFromDef = const configPanelFromDef =
(definition as any)?.configPanel ?? (definition as any)?.config_panel; (definition as any)?.configPanel ?? (definition as any)?.config_panel;
if (configPanelFromDef) { if (configPanelFromDef) {
@@ -32,8 +32,10 @@ import {
import type { FlowDefinition, FlowStep } from "@/types/flow"; import type { FlowDefinition, FlowStep } from "@/types/flow";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { SingleTableWithSticky } from "@/lib/registry/components/table-list/SingleTableWithSticky"; import {
import type { ColumnConfig } from "@/lib/registry/components/table-list/types"; SingleTableWithSticky,
type ColumnConfig,
} from "@/lib/registry/components/table/_shared/SingleTableWithSticky";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
@@ -10,6 +10,7 @@ import { useActiveTab } from "@/contexts/ActiveTabContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
// 확장된 TabItem 타입 (screen_id 지원) // 확장된 TabItem 타입 (screen_id 지원)
interface ExtendedTabItem extends TabItem { interface ExtendedTabItem extends TabItem {
@@ -142,11 +143,11 @@ export function TabsWidget({
for (const tab of tabs as ExtendedTabItem[]) { for (const tab of tabs as ExtendedTabItem[]) {
const inlineComponents = tab.components || []; const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) { if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출 // 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
const tableComp = inlineComponents.find( // (canonical table / legacy table-list / hidden v2-table-list 모두 인식,
(c) => c.component_type === "v2-table-list" || c.component_type === "table-list", // camelCase / snake_case 양쪽 모두 처리)
); const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
const selectedTable = tableComp?.component_config?.selectedTable; const selectedTable = getTableNameFromTableLikeComponent(tableComp);
if (selectedTable || tab.screen_id) { if (selectedTable || tab.screen_id) {
map[tab.id] = { map[tab.id] = {
id: tab.screen_id, id: tab.screen_id,
@@ -229,7 +229,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// ============================================================ // ============================================================
// repeaterDataChange 이벤트 발행 // repeaterDataChange 이벤트 발행
// 데이터 변경 시 다른 컴포넌트(aggregation-widget 등)에 알림 // 데이터 변경 시 다른 컴포넌트(canonical stats 등)에 알림
// ============================================================ // ============================================================
const prevDataLengthRef = useRef(data.length); const prevDataLengthRef = useRef(data.length);
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
import React, { forwardRef, useMemo } from "react"; import React, { forwardRef, useMemo } from "react";
import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent"; import { TableListComponent } from "@/lib/registry/components/table/_shared/TableListComponent";
import { V2ListProps } from "@/types/v2-components"; import { V2ListProps } from "@/types/v2-components";
/** /**
@@ -71,6 +71,7 @@ import {
import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel"; import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel"; import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel";
import type { ComponentData } from "@/types/screen"; import type { ComponentData } from "@/types/screen";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// ─────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────
// 상수: 액션 / 표시 / 변형 // 상수: 액션 / 표시 / 변형
@@ -112,6 +113,16 @@ const MODAL_SIZE_OPTIONS = [
{ value: "full", label: "전체" }, { value: "full", label: "전체" },
] as const; ] as const;
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
const isDataTransferComponentType = (typeValue: unknown): boolean => {
if (isTableLikeComponentType(typeValue)) return true;
if (typeof typeValue !== "string") return false;
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
};
const TRANSFER_MODE_OPTIONS = [ const TRANSFER_MODE_OPTIONS = [
{ value: "append", label: "추가" }, { value: "append", label: "추가" },
{ value: "replace", label: "교체" }, { value: "replace", label: "교체" },
@@ -810,9 +821,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
{p.allComponents {p.allComponents
.filter((c: any) => { .filter((c: any) => {
const t = c.componentType || c.type || ""; const t = c.componentType || c.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some( return isDataTransferComponentType(t);
(x) => t.includes(x),
);
}) })
.map((c: any) => ( .map((c: any) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
@@ -841,9 +850,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
{p.allComponents {p.allComponents
.filter((c: any) => { .filter((c: any) => {
const t = c.componentType || c.type || ""; const t = c.componentType || c.type || "";
const ok = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const ok = isDataTransferComponentType(t);
(x) => t.includes(x),
);
return ok && c.id !== dt.sourceComponentId; return ok && c.id !== dt.sourceComponentId;
}) })
.map((c: any) => ( .map((c: any) => (
File diff suppressed because it is too large Load Diff
@@ -22,8 +22,8 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Table2, Settings, ChevronDown } from "lucide-react"; import { Table2, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel"; import { TableListConfigPanel } from "@/lib/registry/components/table/_shared/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types"; import type { TableListConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
interface V2ListConfigPanelProps { interface V2ListConfigPanelProps {
config: Record<string, any>; config: Record<string, any>;
@@ -1,679 +0,0 @@
"use client";
/**
* V2StatusCount
* UX: 데이터 -> -> -> ()
* StatusCountConfigPanel의 UI로
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Separator } from "@/components/ui/separator";
import {
Table2,
Columns3,
Check,
ChevronsUpDown,
Loader2,
Link2,
Plus,
Trash2,
BarChart3,
Type,
Maximize2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi, type EntityJoinConfig } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
import type { StatusCountConfig, StatusCountItem } from "@/lib/registry/components/v2-status-count/types";
import { STATUS_COLOR_MAP } from "@/lib/registry/components/v2-status-count/types";
const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP);
// ─── 카드 크기 선택 카드 ───
const SIZE_CARDS = [
{ value: "sm", title: "작게", description: "컴팩트" },
{ value: "md", title: "보통", description: "기본 크기" },
{ value: "lg", title: "크게", description: "넓은 카드" },
] as const;
// ─── 섹션 헤더 컴포넌트 ───
function SectionHeader({ icon: Icon, title, description }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
</div>
);
}
// ─── 수평 라벨 + 컨트롤 Row ───
function LabeledRow({ label, description, children }: {
label: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground">{label}</p>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
{children}
</div>
);
}
interface V2StatusCountConfigPanelProps {
config: StatusCountConfig;
onChange: (config: Partial<StatusCountConfig>) => void;
}
export const V2StatusCountConfigPanel: React.FC<V2StatusCountConfigPanelProps> = ({
config,
onChange,
}) => {
// componentConfigChanged 이벤트 발행 래퍼
const handleChange = useCallback((newConfig: Partial<StatusCountConfig>) => {
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...newConfig } },
})
);
}
}, [onChange, config]);
const updateField = useCallback((key: keyof StatusCountConfig, value: any) => {
handleChange({ [key]: value });
}, [handleChange]);
// ─── 상태 ───
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [columns, setColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [entityJoins, setEntityJoins] = useState<EntityJoinConfig[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingJoins, setLoadingJoins] = useState(false);
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [statusColumnOpen, setStatusColumnOpen] = useState(false);
const [relationOpen, setRelationOpen] = useState(false);
const items = config.items || [];
// ─── 테이블 목록 로드 ───
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const result = await tableTypeApi.getTables();
setTables(
(result || []).map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.tableName || t.table_name,
}))
);
} catch (err) {
console.error("테이블 목록 로드 실패:", err);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// ─── 선택된 테이블의 컬럼 + 엔티티 조인 로드 ───
useEffect(() => {
if (!config.tableName) {
setColumns([]);
setEntityJoins([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const result = await tableTypeApi.getColumns(config.tableName);
setColumns(
(result || []).map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name,
}))
);
} catch (err) {
console.error("컬럼 목록 로드 실패:", err);
} finally {
setLoadingColumns(false);
}
};
const loadEntityJoins = async () => {
setLoadingJoins(true);
try {
const result = await entityJoinApi.getEntityJoinConfigs(config.tableName);
setEntityJoins(result?.joinConfigs || []);
} catch (err) {
console.error("엔티티 조인 설정 로드 실패:", err);
setEntityJoins([]);
} finally {
setLoadingJoins(false);
}
};
loadColumns();
loadEntityJoins();
}, [config.tableName]);
// ─── 상태 컬럼의 카테고리 값 로드 ───
useEffect(() => {
if (!config.tableName || !config.statusColumn) {
setStatusCategoryValues([]);
return;
}
const loadCategoryValues = async () => {
setLoadingCategoryValues(true);
try {
const response = await apiClient.get(
`/table-categories/${config.tableName}/${config.statusColumn}/values`
);
if (response.data?.success && response.data?.data) {
const flatValues: Array<{ value: string; label: string }> = [];
const flatten = (categoryItems: any[]) => {
for (const item of categoryItems) {
flatValues.push({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
});
if (item.children?.length > 0) flatten(item.children);
}
};
flatten(response.data.data);
setStatusCategoryValues(flatValues);
}
} catch {
setStatusCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
};
loadCategoryValues();
}, [config.tableName, config.statusColumn]);
// ─── 엔티티 관계 Combobox 아이템 ───
const relationComboItems = useMemo(() => {
return entityJoins.map((ej) => {
const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable;
return {
value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`,
label: `${ej.sourceColumn} -> ${refTableLabel}`,
sublabel: `${ej.referenceTable}.${ej.referenceColumn}`,
};
});
}, [entityJoins, tables]);
const currentRelationValue = useMemo(() => {
if (!config.relationColumn) return "";
return relationComboItems.find((item) => {
const [srcCol] = item.value.split("::");
return srcCol === config.relationColumn;
})?.value || "";
}, [config.relationColumn, relationComboItems]);
// ─── 상태 항목 관리 ───
const addItem = useCallback(() => {
updateField("items", [...items, { value: "", label: "새 상태", color: "gray" }]);
}, [items, updateField]);
const removeItem = useCallback((index: number) => {
updateField("items", items.filter((_: StatusCountItem, i: number) => i !== index));
}, [items, updateField]);
const updateItem = useCallback((index: number, key: keyof StatusCountItem, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [key]: value };
updateField("items", newItems);
}, [items, updateField]);
// ─── 테이블 변경 핸들러 ───
const handleTableChange = useCallback((newTableName: string) => {
handleChange({ tableName: newTableName, statusColumn: "", relationColumn: "", parentColumn: "" });
setTableComboboxOpen(false);
}, [handleChange]);
// ─── 렌더링 ───
return (
<div className="space-y-4">
{/* ═══════════════════════════════════════ */}
{/* 1단계: 데이터 소스 (테이블 선택) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Table2} title="데이터 소스" description="상태를 집계할 테이블을 선택하세요" />
<Separator />
{/* 제목 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Type className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"></span>
</div>
<Input
value={config.title || ""}
onChange={(e) => updateField("title", e.target.value)}
placeholder="예: 일련번호 현황"
className="h-7 text-xs"
/>
</div>
{/* 테이블 선택 */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: config.tableName
? tables.find((t) => t.tableName === config.tableName)?.displayName || config.tableName
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", config.tableName === table.tableName ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* ═══════════════════════════════════════ */}
{/* 2단계: 컬럼 매핑 */}
{/* ═══════════════════════════════════════ */}
{config.tableName && (
<div className="space-y-3">
<SectionHeader icon={Columns3} title="컬럼 매핑" description="상태 컬럼과 부모 관계를 설정하세요" />
<Separator />
{/* 상태 컬럼 */}
<div className="space-y-1">
<span className="text-xs font-medium truncate"> *</span>
<Popover open={statusColumnOpen} onOpenChange={setStatusColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={statusColumnOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingColumns}
>
<span className="truncate">
{loadingColumns
? "컬럼 로딩 중..."
: config.statusColumn
? columns.find((c) => c.columnName === config.statusColumn)?.columnLabel || config.statusColumn
: "상태 컬럼 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.columnName}
value={`${col.columnLabel} ${col.columnName}`}
onSelect={() => {
updateField("statusColumn", col.columnName);
setStatusColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", config.statusColumn === col.columnName ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{col.columnLabel}</span>
{col.columnLabel !== col.columnName && (
<span className="text-[10px] text-muted-foreground/70">{col.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 엔티티 관계 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium truncate"> </span>
</div>
{loadingJoins ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : entityJoins.length > 0 ? (
<Popover open={relationOpen} onOpenChange={setRelationOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={relationOpen}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{currentRelationValue
? relationComboItems.find((r) => r.value === currentRelationValue)?.label || "관계 선택"
: "엔티티 관계 선택"}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="관계 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{relationComboItems.map((item) => (
<CommandItem
key={item.value}
value={`${item.label} ${item.sublabel}`}
onSelect={() => {
if (item.value === currentRelationValue) {
handleChange({ relationColumn: "", parentColumn: "" });
} else {
const [sourceCol, refPart] = item.value.split("::");
const [, refCol] = refPart.split(".");
handleChange({ relationColumn: sourceCol, parentColumn: refCol });
}
setRelationOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", currentRelationValue === item.value ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span>{item.label}</span>
<span className="text-[10px] text-muted-foreground/70">{item.sublabel}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="rounded-lg border-2 border-dashed py-3 text-center">
<p className="text-[10px] text-muted-foreground"> </p>
</div>
)}
{config.relationColumn && config.parentColumn && (
<div className="rounded bg-muted/50 px-2 py-1.5 text-[10px] text-muted-foreground">
FK: <span className="font-medium text-foreground">{config.relationColumn}</span>
{" -> "}
: <span className="font-medium text-foreground">{config.parentColumn}</span>
</div>
)}
</div>
</div>
)}
{/* 테이블 미선택 안내 */}
{!config.tableName && (
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
)}
{/* ═══════════════════════════════════════ */}
{/* 3단계: 카드 크기 (카드 선택 UI) */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<SectionHeader icon={Maximize2} title="카드 크기" description="상태 카드의 크기를 선택하세요" />
<Separator />
<div className="grid grid-cols-3 gap-2">
{SIZE_CARDS.map((card) => {
const isSelected = (config.cardSize || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateField("cardSize", card.value)}
className={cn(
"flex min-h-[60px] flex-col items-center justify-center rounded-lg border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
</button>
);
})}
</div>
</div>
{/* ═══════════════════════════════════════ */}
{/* 4단계: 상태 항목 관리 */}
{/* ═══════════════════════════════════════ */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SectionHeader icon={BarChart3} title="상태 항목" description="집계할 상태 값과 표시 스타일을 설정하세요" />
<Badge variant="secondary" className="text-[10px] h-5">{items.length}</Badge>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-6 shrink-0 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<Separator />
{loadingCategoryValues && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
)}
{items.length === 0 ? (
<div className="rounded-lg border-2 border-dashed py-6 text-center">
<BarChart3 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{items.map((item: StatusCountItem, i: number) => (
<div key={i} className="space-y-1.5 rounded-md border p-2.5">
{/* 첫 번째 줄: 상태값 + 삭제 */}
<div className="flex items-center gap-1">
{statusCategoryValues.length > 0 ? (
<Select
value={item.value || ""}
onValueChange={(v) => {
updateItem(i, "value", v);
if (v === "__ALL__" && !item.label) {
updateItem(i, "label", "전체");
} else {
const catVal = statusCategoryValues.find((cv) => cv.value === v);
if (catVal && !item.label) {
updateItem(i, "label", catVal.label);
}
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="카테고리 값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__" className="text-xs font-medium">
</SelectItem>
{statusCategoryValues.map((cv) => (
<SelectItem key={cv.value} value={cv.value} className="text-xs">
{cv.label} ({cv.value})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={item.value}
onChange={(e) => updateItem(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs"
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(i)}
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 두 번째 줄: 라벨 + 색상 */}
<div className="flex gap-1">
<Input
value={item.label}
onChange={(e) => updateItem(i, "label", e.target.value)}
placeholder="표시 라벨"
className="h-7 text-xs"
/>
<Select
value={item.color}
onValueChange={(v) => updateItem(i, "color", v)}
>
<SelectTrigger className="h-7 w-24 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c} className="text-xs">
<div className="flex items-center gap-1.5">
<div
className={cn("h-3 w-3 rounded-full border", STATUS_COLOR_MAP[c].bg, STATUS_COLOR_MAP[c].border)}
/>
{c}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
<div className="rounded bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
. &gt; .
</div>
)}
{/* 미리보기 */}
{items.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground truncate"></span>
<div className="flex gap-1.5 rounded-md bg-muted/30 p-2">
{items.map((item, i) => {
const colors = STATUS_COLOR_MAP[item.color] || STATUS_COLOR_MAP.gray;
return (
<div
key={i}
className={cn("flex flex-1 flex-col items-center rounded-md border p-1.5", colors.bg, colors.border)}
>
<span className={cn("text-sm font-bold", colors.text)}>0</span>
<span className={cn("text-[10px]", colors.text)}>{item.label || "라벨"}</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};
V2StatusCountConfigPanel.displayName = "V2StatusCountConfigPanel";
export default V2StatusCountConfigPanel;
@@ -47,7 +47,7 @@ import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFil
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { TableListConfig, ColumnConfig } from "@/lib/registry/components/v2-table-list/types"; import type { TableListConfig, ColumnConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
import { CPRow, CPSwitch } from "./_shared/cp"; import { CPRow, CPSwitch } from "./_shared/cp";
// ─── DnD 정렬 가능한 컬럼 행 (접이식) ─── // ─── DnD 정렬 가능한 컬럼 행 (접이식) ───
+2 -22
View File
@@ -52,30 +52,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
try { try {
setLoading(true); setLoading(true);
// 사용자 로케일이 로드될 때까지 잠시 대기
let retryCount = 0;
const maxRetries = 20; // 최대 2초 대기 (100ms * 20)
while (retryCount < maxRetries) {
if (typeof window !== "undefined") {
const hasGlobalLang = !!(window as any).__GLOBAL_USER_LANG;
const hasStoredLang = !!localStorage.getItem("userLocale");
if (hasGlobalLang || hasStoredLang) {
break;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
retryCount++;
}
if (retryCount >= maxRetries) {
console.warn("⚠️ 사용자 로케일 로드 타임아웃, 기본값으로 진행");
}
// 관리자 메뉴와 사용자 메뉴를 병렬로 로드 // 관리자 메뉴와 사용자 메뉴를 병렬로 로드
// 좌측 사이드바용: active만 표시 // 좌측 사이드바용: active만 표시
// 로케일은 useAuth.fetchCurrentUser 가 /auth/me 응답에서 세팅 완료 후 user.company_code 가 채워지므로
// 이 함수가 호출되는 시점에는 항상 __GLOBAL_USER_LANG 이 세팅되어 있음 → 별도 대기 불필요
const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]); const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
if (adminResponse.success && adminResponse.data) { if (adminResponse.success && adminResponse.data) {
+17 -45
View File
@@ -67,22 +67,11 @@ export const useAuth = () => {
const response = await apiCall<UserInfo>("GET", "/auth/me"); const response = await apiCall<UserInfo>("GET", "/auth/me");
if (response.success && response.data) { if (response.success && response.data) {
// 사용자 로케일 정보 조회 const userLocale = response.data.locale || "KR";
try { (window as any).__GLOBAL_USER_LANG = userLocale;
const localeResponse = await apiCall<string>("GET", "/admin/user-locale"); (window as any).__GLOBAL_USER_LOCALE_LOADED = true;
if (localeResponse.success && localeResponse.data) { localStorage.setItem("userLocale", userLocale);
const userLocale = localeResponse.data; localStorage.setItem("userLocaleLoaded", "true");
(window as any).__GLOBAL_USER_LANG = userLocale;
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
localStorage.setItem("userLocale", userLocale);
localStorage.setItem("userLocaleLoaded", "true");
}
} catch {
(window as any).__GLOBAL_USER_LANG = "KR";
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
localStorage.setItem("userLocale", "KR");
localStorage.setItem("userLocaleLoaded", "true");
}
const data = response.data; const data = response.data;
return { return {
@@ -100,30 +89,11 @@ export const useAuth = () => {
} }
}, []); }, []);
/**
*
*/
const checkAuthStatus = useCallback(async (): Promise<AuthStatus> => {
try {
const response = await apiCall<AuthStatus>("GET", "/auth/status");
if (response.success && response.data) {
return {
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
isAdmin: response.data.isAdmin || false,
};
}
return { isLoggedIn: false, isAdmin: false };
} catch {
return { isLoggedIn: false, isAdmin: false };
}
}, []);
/** /**
* *
* - /auth/me * - /auth/me
* - ( fallback * - /auth/me is_admin, locale, force_password_change
* 401 ) * /auth/status, /admin/user-locale ( )
*/ */
const refreshUserData = useCallback(async () => { const refreshUserData = useCallback(async () => {
try { try {
@@ -146,19 +116,21 @@ export const useAuth = () => {
isAdmin: false, isAdmin: false,
}); });
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조 const userInfo = await fetchCurrentUser();
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
if (userInfo) { if (userInfo) {
setUser(userInfo); setUser(userInfo);
const isAdminFromUser = userInfo.user_id === "plm_admin" || userInfo.user_type === "ADMIN"; // 백엔드 AuthService.checkAuthStatus 와 동일한 판정 로직을 user_type 기반으로 적용.
// (별도 /auth/status 호출 없이 동일 결과)
const userType = userInfo.user_type;
const isAdmin = userInfo.user_id === "plm_admin"
|| userType === "ADMIN"
|| userType === "SUPER_ADMIN"
|| userType === "COMPANY_ADMIN";
const finalAuthStatus = { const finalAuthStatus = {
isLoggedIn: true, isLoggedIn: true,
isAdmin: authStatusData.isAdmin || isAdminFromUser, isAdmin,
}; };
setAuthStatus(finalAuthStatus); setAuthStatus(finalAuthStatus);
@@ -178,7 +150,7 @@ export const useAuth = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [fetchCurrentUser, checkAuthStatus]); }, [fetchCurrentUser]);
/** /**
* (Invyone ) * (Invyone )
+111
View File
@@ -0,0 +1,111 @@
/**
* API stub
*
* DB . mock .
* API mock fetch .
*
* ():
* listExecutionHistory GET /api/control/executions?card_id=...&limit=...
* getNodeStats GET /api/control/nodes/{nodeId}/stats
* listNodeComments GET /api/control/nodes/{nodeId}/comments
* listPresence GET /api/control/dashboards/{dashboardId}/presence (WS )
* listRelations GET /api/control/tables/{tableName}/relations
* listRelatedRules GET /api/control/cards/{cardId}/related-rules
*/
export interface ExecutionRecord {
id: string;
ts: string; // HH:MM:SS or full ISO
who: string; // user@domain or "API · webhook"
trig: string; // trigger reference (e.g., shipment_no)
ok: boolean;
ms: number;
steps: number;
err?: string;
}
export interface NodeStats {
valid: boolean;
lastMs: number | null;
runs: number;
alert: string | null;
}
export interface NodeComment {
who: string;
short: string;
color: string; // RGB triplet "0,206,201"
text: string;
at: string; // relative time
}
export interface PresenceUser {
name: string;
short: string;
color: string;
mode: 'edit' | 'view';
}
export interface TableRelation {
from: string;
to: string;
label: string;
type: 'auto' | 'rel';
}
export interface RelatedRule {
id: string;
name: string;
status: 'active' | 'draft' | 'sched';
lastRun: string;
runs: number;
successRate: number | null;
nodes: number;
owner: string;
primary?: boolean;
}
/** 실행 이력 — 카드별 최근 실행 결과 */
export async function listExecutionHistory(
cardId: string,
options: { limit?: number } = {},
): Promise<ExecutionRecord[]> {
void cardId; void options;
// TODO: GET /api/control/executions?card_id=...&limit=...
return Promise.resolve([]);
}
/** 노드 통계 — 단일 노드의 valid/runs/lastMs/alert */
export async function getNodeStats(nodeId: string): Promise<NodeStats> {
void nodeId;
// TODO: GET /api/control/nodes/{nodeId}/stats
return Promise.resolve({ valid: true, lastMs: null, runs: 0, alert: null });
}
/** 노드 댓글 */
export async function listNodeComments(nodeId: string): Promise<NodeComment[]> {
void nodeId;
// TODO: GET /api/control/nodes/{nodeId}/comments
return Promise.resolve([]);
}
/** 현재 보고 있는 사용자 (presence) */
export async function listPresence(dashboardId: string): Promise<PresenceUser[]> {
void dashboardId;
// TODO: GET /api/control/dashboards/{dashboardId}/presence (WS 권장)
return Promise.resolve([]);
}
/** 테이블 관계 (view 모드의 fan-out 트리) */
export async function listRelations(tableName: string): Promise<TableRelation[]> {
void tableName;
// TODO: GET /api/control/tables/{tableName}/relations
return Promise.resolve([]);
}
/** 관련 룰 — 이 카드에 연결된 룰 목록 */
export async function listRelatedRules(cardId: string): Promise<RelatedRule[]> {
void cardId;
// TODO: GET /api/control/cards/{cardId}/related-rules
return Promise.resolve([]);
}
+8
View File
@@ -37,6 +37,14 @@ export const ddlApi = {
return response.data; return response.data;
}, },
/**
* (ALTER TABLE ... DROP COLUMN)
*/
dropColumn: async (tableName: string, columnName: string): Promise<DDLExecutionResult> => {
const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`);
return response.data;
},
/** /**
* ( ) * ( )
*/ */
+84
View File
@@ -191,3 +191,87 @@ export async function setPrimaryDepartment(deptCode: string, userId: string) {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
// ──────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트
// ──────────────────────────────────────────────────
export type BulkAction = "create" | "update_department" | "update_manager";
export interface BulkPreviewRow extends Record<string, any> {
row_index: number;
result: "ok" | "error";
error_detail: string | null;
}
/**
* read-only validation, write .
* action create/update_department/update_manager .
* rows element result(ok|error), error_detail .
*/
export async function bulkPreviewDepartments(
companyCode: string,
action: BulkAction,
rows: Record<string, any>[],
) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { rows: BulkPreviewRow[]; ok_count: number; error_count: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/preview`, { action, rows });
return response.data;
} catch (error: any) {
console.error("일괄 미리보기 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* (, all-or-nothing).
* rows ok row .
*/
export async function bulkCreateDepartments(companyCode: string, rows: Record<string, any>[]) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { inserted: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/create`, { rows });
return response.data;
} catch (error: any) {
console.error("일괄등록 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* (). mode = department | manager.
* row dept_code .
*/
export async function bulkUpdateDepartments(
companyCode: string,
mode: "department" | "manager",
rows: Record<string, any>[],
) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { updated: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/update`, { mode, rows });
return response.data;
} catch (error: any) {
console.error("일괄업데이트 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
@@ -350,6 +350,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지) // 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
// ★ 2026-04-11: INVYONE 통합 컴포넌트(Phase A~) 는 v2- 매핑에서 제외. // ★ 2026-04-11: INVYONE 통합 컴포넌트(Phase A~) 는 v2- 매핑에서 제외.
// ★ 2026-05-12: V2 입력/선택은 완전 폐기 — 매핑/alias/fallback 모두 제거. // ★ 2026-05-12: V2 입력/선택은 완전 폐기 — 매핑/alias/fallback 모두 제거.
// ★ 2026-05-18: canonical data-view 정리 — chart / card-list / grouped-table
// 세 가지를 first-class 로 포함시켜 v2- prefix 자동 매핑(`v2-chart` 등) 으로
// 엉뚱한 컴포넌트가 잡히지 않도록 한다.
const INVYONE_UNIFIED_IDS = new Set([ const INVYONE_UNIFIED_IDS = new Set([
"divider", "divider",
"title", "title",
@@ -360,6 +363,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// "form" 롤백됨 — 3뷰 탭 구조로 처리 예정 // "form" 롤백됨 — 3뷰 탭 구조로 처리 예정
"table", "table",
"container", "container",
"chart",
"card-list",
"grouped-table",
]); ]);
// ── Phase E: v2-* → 통합 컴포넌트 역방향 alias (기존 저장 화면 호환) ── // ── Phase E: v2-* → 통합 컴포넌트 역방향 alias (기존 저장 화면 호환) ──
@@ -380,7 +386,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// table // table
"v2-table-list": "table", "table-list": "table", "v2-table-list": "table", "table-list": "table",
// container // container
"v2-tabs-widget": "container", "v2-section-card": "container", "v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container",
"v2-section-card": "container",
"v2-section-paper": "container", "v2-repeat-container": "container", "v2-section-paper": "container", "v2-repeat-container": "container",
"section-card": "container", "section-paper": "container", "section-card": "container", "section-paper": "container",
"accordion-basic": "container", "accordion-basic": "container",
@@ -1,55 +0,0 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// 탭 컴포넌트 렌더러
const TabsRenderer: ComponentRenderer = ({ component, children, ...props }) => {
const config = component.component_config || {};
const {
tabs = [
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
{ id: "tab3", label: "탭 3", content: "세 번째 탭 내용" },
],
defaultTab = "tab1",
orientation = "horizontal", // horizontal, vertical
style = {},
} = config;
return (
<div className="h-full w-full p-2" style={style}>
<Tabs defaultValue={defaultTab} orientation={orientation} className="h-full">
<TabsList className="grid w-full grid-cols-3">
{tabs.map((tab: any) => (
<TabsTrigger key={tab.id} value={tab.id} className="pointer-events-none" disabled>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab: any) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4 flex-1">
{children && React.Children.count(children) > 0 ? (
children
) : (
<div className="flex h-full items-center justify-center rounded border border-dashed border-input bg-muted">
<div className="text-center">
<div className="text-sm text-muted-foreground">{tab.content}</div>
<div className="mt-1 text-xs text-muted-foreground/70"> </div>
</div>
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("tabs", TabsRenderer);
componentRegistry.register("tabs-horizontal", TabsRenderer);
export { TabsRenderer };
@@ -1,314 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { formatNumber } from "@/lib/formatting";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
externalData?: any[];
}
/**
*
*
*/
export function AggregationWidgetComponent({
component,
isDesignMode = false,
config: propsConfig,
externalData,
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getTranslatedText } = useScreenMultiLang();
const getText = (key: string | undefined) => key ? getTranslatedText(key, key) : undefined;
const componentConfig: AggregationWidgetConfig = {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
...propsConfig,
...component?.config,
};
// 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => {
if (item.labelLangKey) {
const translated = getText(item.labelLangKey);
if (translated && translated !== item.labelLangKey) {
return translated;
}
}
return item.columnLabel || item.columnName || "컬럼";
};
const {
dataSourceType,
dataSourceComponentId,
items,
layout,
showLabels,
showIcons,
gap,
backgroundColor,
borderRadius,
padding,
fontSize,
labelFontSize,
valueFontSize,
labelColor,
valueColor,
} = componentConfig;
// 데이터 상태
const [data, setData] = useState<any[]>([]);
// 외부 데이터가 있으면 사용
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
setData(externalData);
}
}, [externalData]);
// 컴포넌트 데이터 변경 이벤트 리스닝
useEffect(() => {
if (!dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
setData(eventData);
}
};
// 리피터 데이터 변경 이벤트
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
};
}, [dataSourceComponentId, isDesignMode]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
if (!items || items.length === 0) {
return [];
}
return items.map((item) => {
const values = data
.map((row) => {
const val = row[item.columnName];
return typeof val === "number" ? val : parseFloat(val) || 0;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
case "sum":
value = values.reduce((acc, v) => acc + v, 0);
break;
case "avg":
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
break;
case "count":
value = data.length;
break;
case "max":
value = values.length > 0 ? Math.max(...values) : 0;
break;
case "min":
value = values.length > 0 ? Math.min(...values) : 0;
break;
}
// 포맷팅
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = formatNumber(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = formatNumber(value);
}
if (item.prefix) {
formattedValue = `${item.prefix}${formattedValue}`;
}
if (item.suffix) {
formattedValue = `${formattedValue}${item.suffix}`;
}
return {
id: item.id,
label: getItemLabel(item),
value,
formattedValue,
type: item.type,
};
});
}, [data, items, getText]);
// 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => {
switch (type) {
case "sum":
return <Calculator className="h-4 w-4" />;
case "avg":
return <TrendingUp className="h-4 w-4" />;
case "count":
return <Hash className="h-4 w-4" />;
case "max":
return <ArrowUp className="h-4 w-4" />;
case "min":
return <ArrowDown className="h-4 w-4" />;
}
};
// 집계 타입 라벨
const getTypeLabel = (type: AggregationType) => {
switch (type) {
case "sum":
return "합계";
case "avg":
return "평균";
case "count":
return "개수";
case "max":
return "최대";
case "min":
return "최소";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
items.length > 0
? items.map((item) => ({
id: item.id,
label: getItemLabel(item),
value: 0,
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
type: item.type,
}))
: [
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
];
return (
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{previewItems.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
// 실제 렌더링
if (aggregationResults.length === 0) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
</div>
);
}
return (
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{aggregationResults.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
export const AggregationWidgetWrapper = AggregationWidgetComponent;
@@ -1,539 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { AggregationWidgetConfig, AggregationItem, AggregationType } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
interface AggregationWidgetConfigPanelProps {
config: AggregationWidgetConfig;
onChange: (config: Partial<AggregationWidgetConfig>) => void;
screenTableName?: string;
}
/**
*
*/
export function AggregationWidgetConfigPanel({
config,
onChange,
screenTableName,
}: AggregationWidgetConfigPanelProps) {
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string; inputType?: string; web_type?: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 실제 사용할 테이블 이름 계산
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
// 화면 테이블명 자동 설정 (초기 한 번만)
useEffect(() => {
if (screenTableName && !config.tableName && !config.customTableName) {
onChange({ tableName: screenTableName });
}
}, [screenTableName, config.tableName, config.customTableName, onChange]);
// 전체 테이블 목록 로드
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
}))
);
} catch (error) {
console.error("테이블 목록 가져오기 실패:", error);
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
// 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setColumns(
result.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type,
inputType: col.inputType || col.input_type,
web_type: col.web_type || col.webType,
}))
);
} else {
setColumns([]);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName]);
// 집계 항목 추가
const addItem = () => {
const newItem: AggregationItem = {
id: `agg-${Date.now()}`,
columnName: "",
columnLabel: "",
type: "sum",
format: "number",
decimalPlaces: 0,
};
onChange({
items: [...(config.items || []), newItem],
});
};
// 집계 항목 삭제
const removeItem = (id: string) => {
onChange({
items: (config.items || []).filter((item) => item.id !== id),
});
};
// 집계 항목 업데이트
const updateItem = (id: string, updates: Partial<AggregationItem>) => {
onChange({
items: (config.items || []).map((item) =>
item.id === id ? { ...item, ...updates } : item
),
});
};
// 숫자형 컬럼만 필터링 (count 제외) - 입력 타입(inputType/webType)으로만 확인
const numericColumns = columns.filter((col) => {
const inputType = (col.inputType || col.web_type || "")?.toLowerCase();
return (
inputType === "number" ||
inputType === "decimal" ||
inputType === "integer" ||
inputType === "float" ||
inputType === "currency" ||
inputType === "percent"
);
});
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 테이블 설정 (컴포넌트 개발 가이드 준수) */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
{/* 현재 선택된 테이블 표시 (카드 형태) */}
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-primary" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
</div>
<div className="text-[10px] text-muted-foreground">
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
</div>
</div>
</div>
{/* 테이블 선택 Combobox */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
{/* 그룹 1: 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
key={`default-${screenTableName}`}
value={screenTableName}
onSelect={() => {
onChange({
useCustomTable: false,
customTableName: undefined,
tableName: screenTableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.useCustomTable ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-primary" />
{screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 그룹 2: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{availableTables
.filter((table) => table.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
onChange({
useCustomTable: true,
customTableName: table.tableName,
tableName: table.tableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.layout || "horizontal"}
onValueChange={(value) => onChange({ layout: value as "horizontal" | "vertical" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="vertical"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.gap || "16px"}
onChange={(e) => onChange({ gap: e.target.value })}
placeholder="16px"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="showLabels"
checked={config.showLabels ?? true}
onCheckedChange={(checked) => onChange({ showLabels: checked as boolean })}
/>
<Label htmlFor="showLabels" className="text-xs">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="showIcons"
checked={config.showIcons ?? true}
onCheckedChange={(checked) => onChange({ showIcons: checked as boolean })}
/>
<Label htmlFor="showIcons" className="text-xs">
</Label>
</div>
</div>
</div>
{/* 집계 항목 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<hr className="border-border" />
{(config.items || []).length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-3">
{(config.items || []).map((item, index) => (
<div
key={item.id}
className="rounded-md border bg-slate-50 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<span className="text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{/* 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={item.columnName}
onValueChange={(value) => {
const col = columns.find((c) => c.columnName === value);
updateItem(item.id, {
columnName: value,
columnLabel: col?.label || value,
});
}}
disabled={loadingColumns || columns.length === 0}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={
loadingColumns
? "로딩 중..."
: columns.length === 0
? "테이블을 선택하세요"
: "컬럼 선택"
} />
</SelectTrigger>
<SelectContent>
{(item.type === "count" ? columns : numericColumns).length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
{item.type === "count"
? "컬럼이 없습니다"
: "숫자형 컬럼이 없습니다"}
</div>
) : (
(item.type === "count" ? columns : numericColumns).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 집계 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.type}
onValueChange={(value) => updateItem(item.id, { type: value as AggregationType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시 라벨 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={item.columnLabel || ""}
onChange={(e) => updateItem(item.id, { columnLabel: e.target.value })}
placeholder="표시될 라벨"
className="h-7 text-xs"
/>
</div>
{/* 표시 형식 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.format || "number"}
onValueChange={(value) =>
updateItem(item.id, { format: value as "number" | "currency" | "percent" })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="percent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접두사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.prefix || ""}
onChange={(e) => updateItem(item.id, { prefix: e.target.value })}
placeholder="예: ₩"
className="h-7 text-xs"
/>
</div>
{/* 접미사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.suffix || ""}
onChange={(e) => updateItem(item.id, { suffix: e.target.value })}
placeholder="예: 원, 개"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 스타일 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="color"
value={config.backgroundColor || "#f8fafc"}
onChange={(e) => onChange({ backgroundColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.borderRadius || "6px"}
onChange={(e) => onChange({ borderRadius: e.target.value })}
placeholder="6px"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.labelColor || "#64748b"}
onChange={(e) => onChange({ labelColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.valueColor || "#0f172a"}
onChange={(e) => onChange({ valueColor: e.target.value })}
className="h-8"
/>
</div>
</div>
</div>
</div>
);
}
@@ -1,12 +0,0 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { AggregationWidgetDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(AggregationWidgetDefinition);
}
export {};
@@ -1,43 +0,0 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
import { AggregationWidgetConfigPanel } from "./AggregationWidgetConfigPanel";
import type { AggregationWidgetConfig } from "./types";
/**
* AggregationWidget
* (, , )
*/
export const AggregationWidgetDefinition = createComponentDefinition({
id: "aggregation-widget",
name: "집계 위젯",
name_eng: "Aggregation Widget",
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: AggregationWidgetWrapper,
default_config: {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
backgroundColor: "#f8fafc",
borderRadius: "6px",
padding: "12px",
} as Partial<AggregationWidgetConfig>,
default_size: { width: 400, height: 60 },
config_panel: AggregationWidgetConfigPanel,
icon: "Calculator",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터"],
version: "1.0.0",
author: "개발팀",
// hidden: true, // v2-aggregation-widget 사용으로 패널에서 숨김
});
// 타입 내보내기
export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types";

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