52 Commits

Author SHA1 Message Date
johngreen 5cd8e72bf0 fix(테이블타입): 탭 너비 컨텐츠에 맞춤 — shadcn TabsTrigger flex-1 기본값 해제
shadcn TabsTrigger 가 기본 flex-1 이라 두 탭이 가로 폭 절반씩 차지해서 너무
넓게 늘어남. flex-none 으로 override 해서 글자/아이콘 폭만큼만 차지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:16:08 +09:00
johngreen 387a1ae611 style(테이블타입): 탭 Chrome outline 스타일 (#33)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m37s
탭 스타일 v5-tab 톤 매칭
2026-05-22 09:49:53 +00:00
johngreen eeb130e3a8 style(테이블타입): 탭 스타일 Chrome 식 outline 으로 변경 — 페이지 탭(v5-tab)과 톤 일치
밑줄(underline) 스타일이 너무 평면이라 사용자가 페이지 상단 v5-tab(브라우저 탭처럼
border 두른 outline 카드 스타일) 과 톤을 맞추길 원함.

- TabsList: 좌측 정렬 + 하단 1px 구분선 + 좌우 padding
- TabsTrigger: 위 모서리만 둥글게 (rounded-t-md), 1px border 둘러, -mb-px 로
  컨테이너 하단선과 겹쳐 카드처럼 떠 보임
- 활성 탭: bg-card + primary 색 텍스트 + 굵게 + 위쪽 2px primary 강조선 (inset shadow)
  + 하단 border 를 카드색으로 덮어 본문과 seamless

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:49:14 +09:00
johngreen 3ffa5c8ff5 chore(테이블관리): 사소 3건 (PR-D) (#32)
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m34s
PR-D: 사소 3건 정리
2026-05-22 06:12:02 +00:00
johngreen acbab68a12 chore(테이블관리): 사소 3건 정리 (PR-D) — 디버그 로그 / dead code / 에러 메시지
1. 운영 console.log/warn 제거 (G15) — page.tsx 의 이모지 prefix 디버그 로그
   (🔍 🔄  🗑️ 📥 📊 📋) 일괄 제거. catch 블록의 console.error 는 추적용으로
   유지. CreateTableModal 의 컬럼 조회 디버그 로그도 정리.

2. useLogTable dead code 정리 (G16) — CreateTableModal 의 useLogTable state,
   handleCreateTable 분기, 주석 처리된 체크박스 UI 모두 제거. 시그니처 안 맞는
   createLogTable 호출 페이로드까지 같이 사라짐. Activity / Checkbox import 도
   필요 없어졌으므로 제거.

3. 에러 메시지 일관화 (G17) — DdlController 와 TableManagementController 의
   "최고 관리자 권한이 필요합니다." 메시지 모두 "최고 관리자(SUPER_ADMIN) 권한이
   필요합니다." 로 통일. 일반 ADMIN 권한 메시지는 그대로 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:11:21 +09:00
johngreen db63ba6901 fix(테이블관리): 중요 4건 (PR-C) (#31)
Build & Deploy to K8s / build-and-deploy (push) Successful in 10m8s
PR-C: 중요 4건 일괄
2026-05-22 06:04:41 +00:00
johngreen ff95c1950e fix(테이블관리): 중요 4건 일괄 수정 (PR-C)
1. getColumnGroup metaCols 확장 — 시스템 자동 생성 컬럼이 5개만 인식되어
   사용자 컬럼에 섞여 보이던 문제. objid / tenant_id / creator / modifier /
   created_at / updated_at 추가 (총 11개).

2. updateColumnWebType 회사코드 격리 — 모든 호출이 company_code='*' 로 저장돼
   회사 관리자가 자기 회사 전용 web_type 변경 시 모든 회사 공통 설정을 건드림.
   컨트롤러에서 @RequestAttribute("company_code") 받아 service 에 전달.

3. validateUniqueConstraints N+1 해소 — hasColumn 이 루프 안에서 매번 호출되어
   UNIQUE 컬럼 N 개일 때 N 번의 information_schema 조회. 루프 밖으로 빼서 1 번.

4. sanitize 강화 — 빈 문자열 / 숫자 시작 / 63자 초과 / SQL 예약어 모두
   IllegalArgumentException 으로 거부. 빈 식별자가 동적 SQL 에 끼어들어 500
   에러 노출되던 패턴 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:04:02 +09:00
hjjeong 8a9285f13e Merge pull request 'fix(배치관리): DB 커넥션 변경 시 테이블 목록이 안 바뀌는 버그' (#30) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m51s
Reviewed-on: #30
2026-05-22 05:57:24 +00:00
johngreen 88b0549a6d fix(테이블관리): 심각 5건 (PR-B) (#29)
Build & Deploy to K8s / build-and-deploy (push) Successful in 7m15s
PR-B: 심각 5건 일괄
2026-05-22 05:50:52 +00:00
johngreen 33f0647c61 fix(테이블관리): 심각 5건 일괄 수정 (PR-B)
1. setTimeout 1초 reload 제거 — saveAllSettings / handleSaveColumn 의
   setTimeout(loadColumnTypes, 1000) 두 곳을 await 직접 호출로. 1초 깜빡임
   + race 해소.

2. typeFilter 자동 해제 시 input_type 변경 — 도넛 차트 필터가 켜진 상태에서
   상세 패널로 input_type 을 다른 타입으로 바꾸면 그 행이 그리드에서 갑자기
   사라지는 혼란. 변경된 타입이 필터와 다르면 자동으로 필터 해제.

3. composite PK 다이얼로그 — 매 PK 토글마다 확인 다이얼로그 뜨던 답답함.
   "이번 세션 다시 묻지 않기" 체크박스 추가. 체크 시 applyPkChange 즉시 호출.

4. 카테고리 매핑 병렬화 — for-of await 직렬 호출을 Promise.allSettled 로
   변경. 메뉴 수십 개 × 컬럼 수 = 수십 초 멈춤 → 한 번의 병렬 호출.

5. 좁은 화면 상세 패널 stuck 해소 — 패널이 열린 상태에서 column=null 이 되면
   X 버튼이 사라져 닫을 수단이 없던 문제. 빈 상태 UI 에도 X 버튼 유지 +
   ESC 키 핸들러 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:50:12 +09:00
hjjeong 8606f0aaa3 Merge remote-tracking branch 'origin/main' into hjjeong 2026-05-22 14:49:50 +09:00
johngreen 24106929fa fix(테이블관리): 블로커 5건 (PR-A) (#28)
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m26s
PR-A: 블로커 5건 일괄 수정
2026-05-22 05:44:32 +00:00
johngreen f530b3cf31 fix(테이블관리): 블로커 5건 일괄 수정 (PR-A)
종합 감사 결과 발견된 블로커 5건:

1. AddColumnModal / CreateTableModal prop 미스매치 — 호출부 tableName/sourceTableName(camelCase)
   vs 인터페이스 table_name/source_table_name(snake_case). 결과적으로 컬럼 추가 기능이 실제로
   동작하지 않았음 (/api/ddl/tables/undefined/columns). 호출부를 인터페이스에 맞춰 통일.

2. IS_NULLABLE 강제 'Y' 덮어쓰기 (backend mapper) — upsertColumnSettings 의 VALUES 절이
   모든 케이스에서 'Y' 리터럴을 박음. NN 토글 후 "컬럼 설정 저장" 누르면 필수 입력 제약이
   조용히 사라짐. COALESCE(#{is_nullable}, 'Y') 로 변경 + ON CONFLICT 절에 IS_NULLABLE
   COALESCE 추가. Service 도 settings 에서 is_nullable 을 읽어 'Y'/'N' 으로 정규화 후 전달.

3. 저장 안 한 편집 침묵 손실 — 우측 패널에서 편집 후 저장 안 누르고 다른 테이블로 이동 시
   변경 사항 소실. hasUnsavedChanges memo 추가 (columns vs originalColumns JSON 비교).
   handleTableSelect 에서 dirty 면 confirm 다이얼로그.

4. PK / NN / IDX / UQ 위치별 비대칭 저장 — 그리드 행 칩은 즉시 저장하는데 우측 상세 패널
   토글은 메모리만 변경하고 저장 버튼 필요. 두 위치 모두 즉시 저장으로 통일 (상세 패널의
   onColumnChange 가 is_nullable / is_unique 필드를 받으면 handleNullableToggle /
   handleUniqueToggle 호출하도록).

5. SUPER_ADMIN role 검증 누락 (backend 보안) — DdlController 의 isSuperAdmin 이 company_code
   == "*" 만 보고 role 클레임 무시. 토큰 변조 또는 "*" 회사 소속만으로 운영 DB DROP / ALTER
   가능. company_code "*" AND role "SUPER_ADMIN" 둘 다 충족 시에만 통과로 변경. 11개 엔드포인트
   에 @RequestAttribute("role") 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:43:15 +09:00
johngreen 99487049fb style(테이블타입): 탭 스타일 Google 식 underline (#27)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m22s
johngreen → main: 탭 스타일 Google 식 underline 으로 변경
2026-05-22 05:04:26 +00:00
johngreen 6233877029 style(테이블타입): 탭 스타일 Google 식 underline 으로 변경 — 가시성 ↑
기존 shadcn 기본 Tabs 가 둥근 알약 (segmented control) 스타일이라 탭이 아니라 토글처럼
보임. 사용자가 "탭이 눈에 잘 안 띈다" 지적.

Gmail/Drive/GitHub/Vercel 공통 패턴인 underline 스타일로 변경:
- TabsList: 전체 폭 + 하단 1px 구분선, 배경/padding 제거
- TabsTrigger: 평면 + 활성 시 2px primary 밑줄 + 글자 색 강조, 비활성은 muted
- 글자 / 아이콘 크기 한 단계 ↑ (text-xs→text-sm, h-3.5→h-4)
- 비활성에도 transparent border-b-2 줘서 layout shift 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:03:52 +09:00
johngreen 4031fe8b60 chore: retrigger CI (gradle plugin download timed out)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:01 +09:00
johngreen a5288647c9 feat(테이블타입): 와이드 모니터 우측 패널 고정 표시 (#26)
Build & Deploy to K8s / build-and-deploy (push) Failing after 5m9s
johngreen → main: 우측 상세 패널 와이드 모니터에서 고정 표시 + 빈 상태 안내
2026-05-22 04:09:46 +00:00
johngreen 7dbeccc182 feat(테이블타입): 와이드 모니터에서 우측 상세 패널 고정 표시 — 슬라이드 in 제거
기존엔 컬럼 행 클릭해야 우측 패널이 슬라이드로 들어옴. 와이드 모니터에선 우측에 큰 빈 공간이
남는데도 패널이 숨어있어 화면이 흔들려 보임 → 일관성 ↓

와이드(xl 이상)에선 패널을 일반 flex child 로 고정해서 항상 표시. 좁은 화면은 기존
오버레이 슬라이드 유지 (가운데 영역 좁아지는 것 방지).

- page.tsx: 우측 패널 컨테이너에 xl: 상태로 relative + 슬라이드 무력화
- ColumnDetailPanel: 컬럼 선택 안 한 상태일 때 "컬럼을 선택해주세요" 빈 상태 안내 UI 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:09:08 +09:00
johngreen c857e4f715 fix(테이블타입): 컬럼 탭 그룹 정리 (#25)
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m33s
johngreen → main: 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리
2026-05-22 03:32:37 +00:00
johngreen cf5f7ef9af fix(테이블타입): 컬럼 탭 그룹 헤더 사용자/시스템 2그룹으로 정리 — 참조 탭과 중복 해소
PR #24 에서 컬럼/참조 탭은 분리했지만 ColumnGrid 의 기존 3그룹 (기본/참조/메타) 헤더가
그대로 남아있어 "참조 정보" 그룹이 컬럼 탭에서도 보이고 참조 탭에서도 보이는 시각적 중복
발생.

DBeaver 의 Columns 탭처럼 모든 컬럼은 컬럼 탭에서 보여야 함 (참조 컬럼 포함). 단,
"참조 정보" 그룹 헤더는 참조 탭이 같은 역할을 하므로 제거.

- "기본 정보" + "참조 정보" → "사용자 컬럼" 으로 합침
- "메타 정보" → "시스템 컬럼" 으로 이름만 평이하게
- 참조 컬럼은 평범한 한 행으로 표시 (타입 칩으로 종류는 식별)
- 미사용 Layers 아이콘 import 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:31:59 +09:00
johngreen 7e71730015 feat(테이블타입): DBeaver 식 탭 분리 + 입력 타입 아이콘 lucide 통일 (#24)
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m4s
johngreen → main: 컬럼 그리드 탭 분리 + 아이콘 통일 + CLAUDE.md 컨벤션
2026-05-21 10:22:54 +00:00
johngreen 2d39d17428 feat(테이블타입): 컬럼 그리드 DBeaver 식 탭 분리 — 컬럼 / 참조
ColumnGrid 의 "참조/설정" 컬럼이 두 가지 다른 역할 (entity/code/numbering 의 참조 대상 표시
vs 그 외 타입의 default_value 표시) 을 한 셀에 욱여넣고 있었음. text/number/date 행에선
대부분 — 빈 칸. 진단 결과는 architect 가 동의한 대로 컬럼 통째 제거가 답.

DBeaver 의 Columns / Foreign Keys 탭 분리 패턴 차용:
- 컬럼 탭 (기본 활성) — 컬럼 본연의 속성만 (라벨 / 타입 / PK NN IDX UQ / ⋯). 참조/설정 컬럼
  통째 제거, grid 6→5 컬럼으로 슬림화. 라벨 셀 폭 1fr 로 확보
- 참조 탭 — entity / code / category / numbering 컬럼만 모아 표 형태로. 종류별 그룹 헤더
  + (소스 컬럼 / 참조 종류 / 참조 대상) 3컬럼 그리드. code 행에서 누락되어있던 코드 그룹
  표시도 같이 채움

ReferenceListView 신규 컴포넌트로 분리. 사용자가 행 클릭하면 우측 상세 패널 슬라이드 in
하는 기존 동작은 양 탭 모두 동일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:55 +09:00
johngreen 30ebb14023 fix(테이블타입): 입력 타입 아이콘 lucide 로 통일 — letter/symbol/emoji 혼재 정리
기존엔 iconChar 문자열에 'T'(글자) / '#'(기호) / '{}'(코드) / '📎'(emoji) 식으로 일관성 없게
표시. 카드 그리드에서 시각 노이즈 큼.

- TypeColorConfig.iconChar(string) → Icon(LucideIcon) 으로 교체
- 13개 입력 타입 전부 lucide 아이콘 매핑 (Type/Hash/Calendar/Braces/Link2 등)
- FALLBACK_TYPE_CONFIG export 해서 TypeOverviewStrip 의 legacy/unknown 도 같은 인터페이스 따름

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:30 +09:00
johngreen 895cb48ee0 docs(claude): UI/구조 변경 제안 시 ASCII Before/After 그림 의무화
기존 컨벤션에 § 💬 추가. 글로만 설명하면 사용자가 이해 못 함 — 변경 전/후 두 그림을 무조건
그려서 보여줘야 함. 코드 인용 (file:line, CSS class) 최소화하고 영문/SQL/전문용어 풀어쓰기.
3줄 패턴 (무슨 일 / 사용자 영향 / 어떻게 고치는지) 권장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:21:13 +09:00
hjjeong 067193efa9 fix(배치관리): 대시보드 NaN 제거 + 24시간 차트 더미데이터 → 실데이터
- 백엔드: getBatchManagementGlobalSparklineData 쿼리 추가 (generate_series 로
  24개 슬롯 고정, 회사 전체 배치 LEFT JOIN 집계)
- 백엔드: GET /api/batch-management/sparkline 엔드포인트 추가
- 프론트: BatchStats/SparklineData 타입을 백엔드 mapper 의 snake_case 응답키와
  일치시킴 (today_count, today_failed_count, hour_slot, success_count, ...).
  키 미스매치로 stats 카드가 NaN 으로 표시되던 버그 해소
- 프론트: GlobalSparkline 컴포넌트의 Math.random() 더미 막대를 실데이터 prop 으로
  교체. row-level Sparkline 도 동일 키 정렬로 정상 렌더되도록 수정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:52 +09:00
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 8a10edd8e1 fix(배치관리): DB 커넥션 변경 시 테이블 목록이 안 바뀌는 버그
- 외부 DB id 비교를 strict === 에서 toString() 기반 string 비교로 변경 — number/string 어느 쪽으로 오든 매칭. find 실패로 toConnection=null 되면 auto-select useEffect 가 "내부 DB" 로 강제 복귀시키던 문제 해소
- 연결 변경 시 toTables/fromTables 즉시 초기화 — fetch 실패해도 직전 DB 의 테이블이 잔존하지 않도록
- 배치 파이프라인 / 외부커넥션 멀티 DB 작업 핸드오프 노트 함께 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:23:24 +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
195 changed files with 17213 additions and 14753 deletions
+79
View File
@@ -1,3 +1,82 @@
<!-- User customizations -->
# 절대 규칙: 검증 없는 주장 금지
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
## 위반 사례 (절대 하지 말 것)
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
## 발화 전 자기 검증
한 문장이라도 출력하기 전에 다음을 확인:
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
## 모를 때의 정답
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
## 어겼을 때
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
- 변명·재포장 금지
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
## 원칙
1. **변경 제안은 무조건 Before / After 두 그림**
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
3. **평어, 한국어, 짧은 문장**
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
## 나쁜 예시 ❌
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
(사용자: "뭐라는지 모르겠어")
## 좋은 예시 ⭕
> **지금 모양:**
> ```
> 라벨·컬럼명 │ 참조/설정 │ 타입
> 거래처명 │ — │ 텍스트 ← 빈 칸
> 거래처ID │ customer_mng → ... │ 테이블참조
> ```
>
> **바꿔서:**
> ```
> 라벨·컬럼명 │ 타입
> 거래처명 │ 텍스트
> 거래처ID │ 테이블참조
> → customer_mng.id ← 정보 있을 때만 작게 밑에
> ```
## 옵션 제시할 땐 표로
```
| 옵션 | 핵심 | 단점 |
| A안 | 이름만 바꾸기 | 가장 가벼움 |
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
```
## 우선 순위
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
---
# INVYONE — Claude 작업 컨벤션
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
+5
View File
@@ -33,6 +33,11 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
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'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core'
@@ -5,9 +5,15 @@ import java.util.Set;
public final class 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(
"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;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -30,13 +32,17 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
params.put("is_management_screen",
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), "관리자 메뉴 목록 조회 성공"));
}
@@ -49,11 +55,15 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
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), "사용자 메뉴 목록 조회 성공"));
}
@@ -136,6 +136,15 @@ public class BatchManagementController {
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
}
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
@GetMapping("/sparkline")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
}
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
@GetMapping("/batch-configs/{id}/recent-logs")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.CompanyManagementService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -16,6 +18,7 @@ import java.util.Map;
@Slf4j
public class CompanyManagementController {
private final SuperAdminGuard guard;
private final CompanyManagementService companyManagementService;
/**
@@ -24,9 +27,12 @@ public class CompanyManagementController {
*/
@DeleteMapping("/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
HttpServletRequest request,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(request);
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
if (body != null) {
@@ -52,7 +58,11 @@ public class CompanyManagementController {
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
*/
@GetMapping("/disk-usage/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
HttpServletRequest request) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
return ResponseEntity.ok(ApiResponse.success(data));
@@ -68,7 +78,11 @@ public class CompanyManagementController {
*/
@GetMapping("/{companyCode}/disk-usage")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
HttpServletRequest request,
@PathVariable String companyCode) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
return ResponseEntity.ok(ApiResponse.success(data));
@@ -29,11 +29,12 @@ public class DdlController {
@PostMapping("/tables")
public ResponseEntity<ApiResponse<?>> createTable(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
String tableName = (String) body.get("table_name");
@@ -65,11 +66,12 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> addColumn(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
@@ -91,6 +93,33 @@ public class DdlController {
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(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
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} - 테이블 삭제
*/
@@ -98,10 +127,11 @@ public class DdlController {
public ResponseEntity<ApiResponse<?>> dropTable(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestAttribute("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
@@ -121,10 +151,11 @@ public class DdlController {
@PostMapping("/validate/table")
public ResponseEntity<ApiResponse<?>> validateTableCreation(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestBody Map<String, Object> body) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
String tableName = (String) body.get("table_name");
@@ -150,12 +181,13 @@ public class DdlController {
@GetMapping("/logs")
public ResponseEntity<ApiResponse<?>> getDdlLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "50") int limit,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String ddlType) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
@@ -169,11 +201,12 @@ public class DdlController {
@GetMapping("/statistics")
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String fromDate,
@RequestParam(required = false) String toDate) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
@@ -186,10 +219,11 @@ public class DdlController {
@GetMapping("/tables/{tableName}/history")
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
@@ -204,10 +238,11 @@ public class DdlController {
@GetMapping("/tables/{tableName}/info")
public ResponseEntity<ApiResponse<?>> getTableInfo(
@PathVariable String tableName,
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
@@ -229,10 +264,11 @@ public class DdlController {
@DeleteMapping("/logs/cleanup")
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
@@ -246,10 +282,11 @@ public class DdlController {
*/
@GetMapping("/info")
public ResponseEntity<ApiResponse<?>> getInfo(
@RequestAttribute("company_code") String companyCode) {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute(value = "role", required = false) String role) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
if (!isSuperAdmin(companyCode, role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
return ResponseEntity.ok(ApiResponse.success(Map.of(
@@ -292,7 +329,9 @@ public class DdlController {
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────
private boolean isSuperAdmin(String companyCode) {
return "*".equals(companyCode);
private boolean isSuperAdmin(String companyCode, String role) {
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
}
}
@@ -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).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
@@ -187,7 +187,8 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
@@ -197,7 +198,8 @@ public class TableManagementController {
}
@SuppressWarnings("unchecked")
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings);
// 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode);
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
}
@@ -272,7 +274,7 @@ public class TableManagementController {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> columns = (List<String>) body.get("columns");
@@ -291,7 +293,7 @@ public class TableManagementController {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
String columnName = (String) body.get("column_name");
String indexType = (String) body.get("index_type");
@@ -320,7 +322,7 @@ public class TableManagementController {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Object nullableObj = body.get("nullable");
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
@@ -342,7 +344,7 @@ public class TableManagementController {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
Object uniqueObj = body.get("unique");
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
@@ -567,7 +569,7 @@ public class TableManagementController {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> logColumns = (List<String>) body.get("log_columns");
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Cross-tenant 어드민 엔드포인트 진입 가드.
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
public static boolean isMetaContext() {
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")
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -92,6 +98,12 @@ public class CrossTenantController {
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request,
@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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -173,6 +185,12 @@ public class CrossTenantController {
Map<String, Object> queryParams,
String mapperId,
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
public ResponseEntity<Map<String, Object>> listDepartments(
HttpServletRequest request,
@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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody("super_admin_required", request.getRequestURI()));
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.RoleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
private final CrossTenantExecutor executor;
private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
}
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, "권한 그룹 생성 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
}
return roleService.updateRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
if (g != null) return g;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("objid", id);
roleService.deleteRoleGroup(p);
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
}
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.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) {
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
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 의 동일 헬퍼 미러. */
private String asYn(Object raw) {
if (raw == null) return null;
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> 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, "사용자 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
}
adminService.changeUserStatus(userId, "inactive");
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_DELETE,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) {
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 userId = (String) body.get("user_id");
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_PW_RESET,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
/** Void 응답용 가드 (제네릭만 다름). */
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
}
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
)
""",
"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)
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
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 ObjectMapper objectMapper;
@@ -100,13 +100,22 @@ public class DataCopier {
try (Statement us = dst.createStatement()) {
for (String[] r : rows) {
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++;
continue;
}
String sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
try {
us.execute(sql);
updated++;
@@ -117,14 +126,8 @@ public class DataCopier {
}
}
}
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
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());
}
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
updated, skippedType, skippedErr, rows.size());
return updated;
}
@@ -135,6 +138,12 @@ public class DataCopier {
|| 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 {
List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.util.Arrays;
@@ -40,13 +37,7 @@ public class ProvisioningController {
private final ProvisioningRegistry registry;
private final SqlSession sqlSession;
private final CompanyStatsService statsService;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
private final SuperAdminGuard guard;
@GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
@@ -208,23 +199,11 @@ public class ProvisioningController {
}
// ------------------------------------------------------------------
// 권한 체크
//
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
// CompanyMgmtController 와 동일한 가드를 공유.
// ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
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");
guard.enforce(request);
}
// --- Validation helpers ---
@@ -1,5 +1,6 @@
package com.erp.provisioning;
import com.erp.tenant.ReservedSubdomains;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.regex.Pattern;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
@Slf4j
public class SuperAdminGuard {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
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");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
String userId = (String) request.getAttribute("user_id");
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;
}
// 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분.
// ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요.
if (isSkippablePath(path)) {
chain.doFilter(request, response);
return;
}
String userId = (String) request.getAttribute("user_id");
String companyCode = (String) request.getAttribute("company_code");
@@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
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");
}
}
@@ -296,6 +296,11 @@ public class BatchManagementService extends BaseService {
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
}
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
}
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
}
@@ -53,6 +53,13 @@ public class CommonCodeService extends BaseService {
@Transactional
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<>();
params.put("code_info", body.get("code_info"));
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
// ─────────────────────────────────────────────────────────────────────────
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
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(
String dbType, Map<String, Object> conn, String password) {
String type = dbType == null ? "" : dbType.toLowerCase();
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 username = str(conn, "username");
String sslEnabled = str(conn, "ssl_enabled");
int connTimeout = toInt(conn, "connection_timeout", 30);
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
if (!"postgresql".equalsIgnoreCase(dbType)) {
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);
String url;
Properties props = new Properties();
props.setProperty("user", username);
props.setProperty("password", password);
props.setProperty("connect_timeout", String.valueOf(connTimeout));
props.setProperty("socket_timeout", "30");
if ("Y".equalsIgnoreCase(sslEnabled)) {
props.setProperty("ssl", "true");
props.setProperty("sslmode", "require");
if (username != null) props.setProperty("user", username);
if (password != null) props.setProperty("password", password);
switch (type) {
case "postgresql" -> {
url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
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);
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
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;
} catch (SQLException e) {
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) ────────────────────────────────────────────
public Map<String, Object> executeQuery(long id, String sql) {
@@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService {
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
//
@@ -356,8 +368,10 @@ public class ScreenGroupService extends BaseService {
}
// 화면 타입 추론
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
// 어느 것이든 있으면 grid 본다.
String screenType = "form";
if (widgetCounts.getOrDefault("table", 0) > 0) {
if (countTableLikeWidgets(widgetCounts) > 0) {
screenType = "grid";
} else if (widgetCounts.getOrDefault("custom", 0) > 2) {
screenType = "dashboard";
@@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService {
if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge);
}
// 화면 타입 추론
// 화면 타입 추론 canonical / legacy / hidden v2 모두 grid 인식
summaryMap.values().forEach(summary -> {
@SuppressWarnings("unchecked")
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");
} else if (wc.getOrDefault("table-search-widget", 0) > 1) {
summary.put("screen_type", "dashboard");
@@ -176,6 +176,21 @@ public class TableManagementService extends BaseService {
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
params.put("display_order", settings.getOrDefault("display_order", 0));
params.put("is_visible", settings.getOrDefault("is_visible", true));
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper COALESCE 기존 유지.
Object rawIsNullable = settings.get("is_nullable");
if (rawIsNullable != null) {
String s = rawIsNullable.toString();
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 있어 정규화
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
params.put("is_nullable", "N");
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
params.put("is_nullable", "Y");
} else {
params.put("is_nullable", null);
}
} else {
params.put("is_nullable", null);
}
params.put("company_code", companyCode);
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
sqlSession.update(NS + "upsertColumnSettings", params);
@@ -200,19 +215,21 @@ public class TableManagementService extends BaseService {
@Transactional
public void updateColumnWebType(String tableName, String columnName,
String webType, Map<String, Object> detailSettings) {
String webType, Map<String, Object> detailSettings,
String companyCode) {
String finalType = normalizeInputType(webType);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
params.put("input_type", finalType);
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
params.put("company_code", "*");
// 멀티테넌트 격리: SUPER_ADMIN("*") 공통 설정, 외는 회사별 설정
params.put("company_code", companyCode != null ? companyCode : "*");
params.put("clear_entity", false);
params.put("clear_code", false);
params.put("clear_category", false);
sqlSession.update(NS + "upsertColumnInputType", params);
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
}
@Transactional
@@ -383,12 +400,14 @@ public class TableManagementService extends BaseService {
String safeTable = sanitize(tableName);
List<String> violations = new ArrayList<>();
// N+N N+1 최적화: hasColumn information_schema 조회라 비싸. 루프 밖에서 번만 수행.
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
for (Map<String, Object> col : uniqueCols) {
String colName = (String) col.get("column_name");
Object val = data.get(colName);
if (val == null) continue;
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
String sql;
List<Object> sqlParams = new ArrayList<>();
@@ -1252,9 +1271,40 @@ public class TableManagementService extends BaseService {
}
/** SQL injection 방지용 식별자 정리 */
/**
* SQL 식별자(테이블/컬럼명) 살균.
* - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
* - 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 IllegalArgumentException
*
* 이렇게 가드해두지 않으면 동적 SQL 식별자가 들어가거나 예약어가 통과해
* 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 생김.
*/
private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
"user", "order", "group", "table", "column", "index", "select", "insert",
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
"null", "true", "false", "create", "alter", "drop", "primary", "key",
"foreign", "references", "constraint", "default", "unique", "check",
"view", "procedure", "function"
);
private String sanitize(String name) {
if (name == null) return "";
return name.replaceAll("[^a-zA-Z0-9_]", "");
if (name == null) {
throw new IllegalArgumentException("식별자가 null 입니다.");
}
String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
if (cleaned.isEmpty()) {
throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
}
if (cleaned.length() > 63) {
throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
}
if (Character.isDigit(cleaned.charAt(0))) {
throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
}
if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
}
return cleaned;
}
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
@@ -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'
)
</if>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -105,6 +108,9 @@
AND RMA.READ_YN = 'Y'
)
</if>
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -124,26 +130,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_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
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
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화'
@@ -152,6 +140,16 @@
FROM V_MENU V
LEFT JOIN COMPANY_MNG CM
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
</select>
@@ -187,6 +185,9 @@
AND MENU.COMPANY_CODE = #{company_code}
</otherwise>
</choose>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -212,6 +213,9 @@
ON S.PARENT_OBJ_ID = V.OBJID
WHERE S.OBJID != ALL(V.PATH)
AND S.STATUS = 'active'
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -231,26 +235,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_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
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
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화'
@@ -259,6 +245,16 @@
FROM V_MENU V
LEFT JOIN COMPANY_MNG CM
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
</select>
@@ -365,26 +361,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_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
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
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
, CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화'
@@ -393,6 +371,16 @@
FROM V_MENU V
LEFT JOIN COMPANY_MNG CM
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
</select>
@@ -87,6 +87,32 @@
ORDER BY hour_slot
</select>
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
WITH hours AS (
SELECT generate_series(
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
DATE_TRUNC('hour', NOW()),
INTERVAL '1 hour'
) AS hour_slot
),
filtered_logs AS (
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
execution_status
FROM batch_execution_logs
WHERE start_time >= NOW() - INTERVAL '24 hours'
<include refid="common.companyCodeFilter"/>
)
SELECT h.hour_slot,
COUNT(l.execution_status) AS total_count,
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
FROM hours h
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
GROUP BY h.hour_slot
ORDER BY h.hour_slot
</select>
<!-- 최근 실행 로그 목록 (최대 20건) -->
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
SELECT id,
@@ -81,7 +81,7 @@
, E.CREATED_DATE
, E.UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS E
WHERE E.ID = #{id}
WHERE E.ID = #{id}::varchar
</select>
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
@@ -109,14 +109,14 @@
, CREATED_DATE
, UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</select>
<!-- 비밀번호만 조회 -->
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
SELECT PASSWORD
FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</select>
<!-- 이름+회사 중복 확인 -->
@@ -134,7 +134,7 @@
FROM EXTERNAL_DB_CONNECTIONS
WHERE CONNECTION_NAME = #{connection_name}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND ID != #{exclude_id}
AND ID != #{exclude_id}::varchar
LIMIT 1
</select>
@@ -166,13 +166,13 @@
, #{description}
, #{db_type}
, #{host}
, #{port}
, #{port}::varchar
, #{database_name}
, #{username}
, #{password}
, #{connection_timeout}
, #{query_timeout}
, #{max_connections}
, #{connection_timeout}::varchar
, #{query_timeout}::varchar
, #{max_connections}::varchar
, #{ssl_enabled}
, #{ssl_cert_path}
, #{connection_options}::JSONB
@@ -193,13 +193,13 @@
<if test="description != null">DESCRIPTION = #{description},</if>
<if test="db_type != null">DB_TYPE = #{db_type},</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="username != null">USERNAME = #{username},</if>
<if test="password != null">PASSWORD = #{password},</if>
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if>
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if>
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if>
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</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_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</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>
UPDATED_DATE = NOW()
</set>
WHERE ID = #{id}
WHERE ID = #{id}::varchar
</update>
<!-- 삭제 -->
<delete id="deleteExternalDbConnection" parameterType="map">
DELETE FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
<if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
@@ -704,11 +704,19 @@
</foreach>
AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save'
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 (
SELECT 1 FROM SCREEN_LAYOUTS SL_LIST
WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID
AND SL_LIST.PROPERTIES->>'componentType' = 'table-list'
AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list')
AND (
(SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE
)
)
AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL
@@ -300,7 +300,7 @@
, #{display_column}
, #{display_order}
, #{is_visible}
, 'Y'
, COALESCE(#{is_nullable}, 'Y')
, #{company_code}
, #{category_ref}
, NOW()
@@ -318,6 +318,7 @@
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
, UPDATED_DATE = NOW()
</insert>
@@ -667,15 +668,15 @@
SET
PROPERTIES = JSONB_SET(
JSONB_SET(
SL.PROPERTIES,
SL.PROPERTIES::JSONB,
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
),
'{componentType}', TO_JSONB(#{component_id}::TEXT)
)
)::TEXT
FROM SCREEN_DEFINITIONS SD
WHERE SL.SCREEN_ID = SD.SCREEN_ID
AND SL.PROPERTIES->>'tableName' = #{table_name}
AND SL.PROPERTIES->>'columnName' = #{column_name}
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
</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";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -64,6 +65,7 @@ import {
import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company";
import { isManagementHost } from "@/lib/tenant/subdomain";
const RESOURCE_TYPE_CONFIG: Record<
string,
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
}
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 isSuperAdmin = user?.company_code === "*";
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
setDetailOpen(true);
};
if (hostBlocked) return null;
return (
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between">
@@ -35,6 +35,7 @@ import {
} from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { Pagination } from "@/components/common/Pagination";
import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string {
@@ -127,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
return (
<div className="flex h-8 items-end gap-[2px]">
{data.map((slot, i) => {
const hasFail = slot.failed > 0;
const hasSuccess = slot.success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
const failed = Number(slot.failed_count) || 0;
const success = Number(slot.success_count) || 0;
const hasFail = failed > 0;
const hasSuccess = success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
const colorClass = hasFail
? "bg-destructive/70 hover:bg-destructive"
: hasSuccess
@@ -140,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
key={i}
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
style={{ height }}
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
/>
);
})}
@@ -277,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
function GlobalSparkline({ data }: { data: SparklineData[] }) {
if (!data || data.length === 0) return null;
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
@@ -293,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
</div>
</div>
<div className="flex h-10 items-end gap-[3px]">
{Array.from({ length: 24 }).map((_, i) => {
const hasExec = Math.random() > 0.3;
const hasFail = hasExec && Math.random() < 0.08;
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
{data.map((slot, i) => {
const success = Number(slot.success_count) || 0;
const failed = Number(slot.failed_count) || 0;
const hasFail = failed > 0;
const hasExec = success > 0 || hasFail;
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
const h = hasFail
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
: hasExec
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
: 6;
const hour = slot.hour_slot?.slice(11, 16) || "";
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
style={{ height: `${h}%` }}
title={`${hour} | 성공 ${success} 실패 ${failed}`}
/>
);
})}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>24 </span>
<span>12 </span>
<span>6 </span>
<span></span>
</div>
</div>
@@ -326,18 +340,25 @@ export default function BatchManagementPage() {
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const [configsResponse, statsData] = await Promise.all([
const [configsResponse, statsData, globalSpark] = await Promise.all([
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
BatchAPI.getGlobalSparkline(),
]);
setGlobalSparkline(globalSpark);
// cross-tenant 메타 (단일 모드면 undefined → null)
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
if (configsResponse.success && configsResponse.data) {
@@ -364,6 +385,9 @@ export default function BatchManagementPage() {
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
// 검색/필터 변경 시 1페이지로 리셋
useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]);
const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId);
@@ -443,14 +467,26 @@ export default function BatchManagementPage() {
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 inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
const todayExec = Number(stats?.today_count) || 0;
const todayFail = Number(stats?.today_failed_count) || 0;
const yestExec = Number(stats?.yesterday_count) || 0;
const yestFail = Number(stats?.yesterday_failed_count) || 0;
const execDiff = todayExec - yestExec;
const failDiff = todayFail - yestFail;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="w-full space-y-4 px-4 py-6 sm:px-6">
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<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">
@@ -486,7 +522,7 @@ export default function BatchManagementPage() {
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
@@ -496,8 +532,8 @@ export default function BatchManagementPage() {
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{todayFail}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
@@ -509,7 +545,7 @@ export default function BatchManagementPage() {
)}
{/* 24시간 차트 */}
<GlobalSparkline stats={stats} />
<GlobalSparkline data={globalSparkline} />
{/* 검색 + 필터 */}
<div className="flex flex-wrap items-center gap-3">
@@ -534,8 +570,8 @@ export default function BatchManagementPage() {
</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 && (
<div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
@@ -549,7 +585,7 @@ export default function BatchManagementPage() {
</div>
)}
{filteredBatches.map((batch) => {
{pagedBatches.map((batch) => {
const batchId = batch.id!;
const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId;
@@ -674,6 +710,29 @@ export default function BatchManagementPage() {
})}
</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 && (
<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,
render: (_v, row) => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
disabled={testingConnections.has(row.id!)}
className="h-9 text-sm">
className="h-7 px-2 text-xs">
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
</Button>
{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!) ? "성공" : "실패"}
</Badge>
)}
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
];
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<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">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p>
<div className="shrink-0 space-y-0.5 border-b pb-3">
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> REST API </p>
</div>
{/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2">
<Database className="h-4 w-4" />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
<Database className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
<Globe className="h-3.5 w-3.5" />
REST API
</TabsTrigger>
</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 flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[260px]">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
className="h-8 pl-9 text-xs"
/>
</div>
<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 타입" />
</SelectTrigger>
<SelectContent>
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<SelectItem key={type.value} value={type.value} className="text-xs">
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
isLoading={loading}
emptyMessage="등록된 연결이 없습니다"
skeletonCount={5}
compact
scrollContainer
cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
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" ? "활성" : "비활성"}
</Badge>
)}
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
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!) ? "테스트 중..." : "테스트"}
</Button>
<Button variant="outline" size="sm"
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
setSelectedConnection(c);
setSqlModalOpen(true);
}}
className="h-9 flex-1 gap-2 text-sm">
<Terminal className="h-4 w-4" />
className="h-7 flex-1 gap-1 text-xs">
<Terminal className="h-3.5 w-3.5" />
SQL
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-9 flex-1 gap-2 text-sm">
<Pencil className="h-4 w-4" />
className="h-7 flex-1 gap-1 text-xs">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
<Trash2 className="h-4 w-4" />
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
</TabsContent>
{/* 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 />
</TabsContent>
</Tabs>
@@ -331,14 +331,14 @@ export default function BatchManagementNewPage() {
// 내부 데이터베이스 선택
connection = connections.find((conn) => conn.type === "internal") || null;
} else {
// 외부 데이터베이스 선택
const connectionId = parseInt(connectionValue);
connection = connections.find((conn) => conn.id === connectionId) || null;
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
}
setToConnection(connection);
setToTable("");
setToColumns([]);
setToTables([]);
if (connection) {
try {
@@ -383,12 +383,12 @@ export default function BatchManagementNewPage() {
if (connectionValue === "internal") {
connection = connections.find((conn) => conn.type === "internal") || null;
} else {
const connectionId = parseInt(connectionValue);
connection = connections.find((conn) => conn.id === connectionId) || null;
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
}
setFromConnection(connection);
setFromTable("");
setFromColumns([]);
setFromTables([]);
if (connection) {
try {
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
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 AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport";
import { isManagementHost } from "@/lib/tenant/subdomain";
const PAGE_SIZE = 10;
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
*
* /admin/userMng/companyList ( CRUD) .
* "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" .
*
* 격리: 솔루션/ (solution.invyone.com, localhost ) .
* (qnc.invyone.com ) URL /main .
* SuperAdminGuard API .
*/
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 [q, setQ] = useState("");
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({
queryKey: ["companies-stats"],
queryFn: getCompaniesStats,
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
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 inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
if (hostBlocked) {
return null;
}
return (
<div
style={{
@@ -20,7 +20,11 @@ import {
Check,
ChevronsUpDown,
Loader2,
Pencil,
Columns3,
Link2,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
@@ -55,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
export default function TableManagementPage() {
const { userLang, getText } = useMultiLang({ companyCode: "*" });
@@ -119,6 +124,9 @@ export default function TableManagementPage() {
// 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
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);
// PK/인덱스 관리 상태
@@ -127,6 +135,8 @@ export default function TableManagementPage() {
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
const [pkDialogOpen, setPkDialogOpen] = useState(false);
// 이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 매번 다이얼로그 뜨는 답답함 해소)
const [pkSkipConfirmSession, setPkSkipConfirmSession] = useState(false);
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
// 선택된 테이블 목록 (체크박스)
@@ -269,11 +279,9 @@ export default function TableManagementPage() {
if (response.success && response.data) {
setSecondLevelMenus(response.data);
} else {
console.warn("⚠️ 2레벨 메뉴 로드 실패:", response);
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
}
} catch (error) {
console.error("❌ 2레벨 메뉴 로드 에러:", error);
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
}
};
@@ -316,12 +324,6 @@ export default function TableManagementPage() {
if (response.data.success) {
const data = response.data.data;
console.log("📥 원본 API 응답:", {
hasColumns: !!(data.columns || data),
firstColumn: (data.columns || data)[0],
statusColumn: (data.columns || data).find((col: any) => col.column_name === "status"),
});
// 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => {
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
@@ -391,9 +393,38 @@ export default function TableManagementPage() {
}
}, []);
// ESC 키로 우측 상세 패널 닫기 (좁은 화면에서 stuck 방지)
useEffect(() => {
if (!selectedColumn) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelectedColumn(null);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [selectedColumn]);
// 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존)
const hasUnsavedChanges = useMemo(() => {
if (columns.length === 0 || originalColumns.length === 0) return false;
if (columns.length !== originalColumns.length) return true;
// 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서)
try {
return JSON.stringify(columns) !== JSON.stringify(originalColumns);
} catch {
return false;
}
}, [columns, originalColumns]);
// 테이블 선택
const handleTableSelect = useCallback(
(tableName: string) => {
if (tableName === selectedTable) return;
if (hasUnsavedChanges) {
const ok = typeof window !== "undefined"
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
: true;
if (!ok) return;
}
setSelectedTable(tableName);
setCurrentPage(1);
setColumns([]);
@@ -408,12 +439,17 @@ export default function TableManagementPage() {
loadColumnTypes(tableName, 1, pageSize);
loadConstraints(tableName);
},
[loadColumnTypes, loadConstraints, pageSize, tables],
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
);
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
const handleInputTypeChange = useCallback(
(columnName: string, newInputType: string) => {
// typeFilter 가 활성화된 상태에서 변경된 input_type 이 필터와 불일치하면 자동으로 필터 해제
// (그렇지 않으면 사용자가 방금 편집한 행이 그리드에서 갑자기 사라져 혼란)
if (typeFilter && typeFilter !== newInputType) {
setTypeFilter(null);
}
setColumns((prev) =>
prev.map((col) => {
if (col.column_name === columnName) {
@@ -604,7 +640,6 @@ export default function TableManagementPage() {
};
finalDetailSettings = JSON.stringify(entitySettings);
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
}
// 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
@@ -624,7 +659,6 @@ export default function TableManagementPage() {
};
finalDetailSettings = JSON.stringify(codeSettings);
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
}
const columnSetting = {
@@ -642,74 +676,39 @@ export default function TableManagementPage() {
// console.log("저장할 컬럼 설정:", columnSetting);
console.log("💾 저장할 컬럼 정보:", {
columnName: column.column_name,
inputType: column.input_type,
categoryMenus: column.category_menus,
hasCategoryMenus: !!column.category_menus,
categoryMenusLength: column.category_menus?.length || 0,
});
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
columnSetting,
]);
if (response.data.success) {
console.log("✅ 컬럼 설정 저장 성공");
// 🆕 Category 타입인 경우 컬럼 매핑 처리
console.log("🔍 카테고리 조건 체크:", {
isCategory: column.input_type === "category",
hasCategoryMenus: !!column.category_menus,
length: column.category_menus?.length || 0,
});
if (column.input_type === "category" && !column.category_ref) {
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable,
columnName: column.column_name,
});
try {
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
} catch (error) {
console.error("❌ 기존 매핑 삭제 실패:", error);
}
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
if (column.category_menus && column.category_menus.length > 0) {
console.log("📥 카테고리 메뉴 매핑 시작:", {
columnName: column.column_name,
categoryMenus: column.category_menus,
count: column.category_menus.length,
});
let successCount = 0;
let failCount = 0;
for (const menuObjid of column.category_menus) {
try {
const mappingResponse = await createColumnMapping({
// 직렬 await 대신 Promise.allSettled 로 병렬 호출 (메뉴가 많으면 직렬은 수십 초 멈춤)
const mappingResults = await Promise.allSettled(
column.category_menus.map((menuObjid) =>
createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.column_name,
physicalColumnName: column.column_name,
menuObjid,
description: `${column.display_name} (메뉴별 카테고리)`,
});
if (mappingResponse.success) {
successCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
failCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
failCount++;
}
}
}),
),
);
const successCount = mappingResults.filter(
(r) => r.status === "fulfilled" && r.value.success,
).length;
const failCount = mappingResults.length - successCount;
if (successCount > 0 && failCount === 0) {
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
@@ -728,10 +727,8 @@ export default function TableManagementPage() {
// 원본 데이터 업데이트
setOriginalColumns((prev) => prev.map((col) => (col.column_name === column.column_name ? column : col)));
// 저장 후 데이터 확인을 위해 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable);
}, 1000);
// 저장 후 데이터 확인을 위해 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
await loadColumnTypes(selectedTable);
} else {
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
@@ -856,69 +853,39 @@ export default function TableManagementPage() {
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
const categoryColumns = columns.filter((col) => col.input_type === "category" && !col.category_ref);
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length,
categoryColumns: categoryColumns.length,
categoryColumnsData: categoryColumns.map((col) => ({
columnName: col.column_name,
categoryMenus: col.category_menus,
})),
});
if (categoryColumns.length > 0) {
let totalSuccessCount = 0;
let totalFailCount = 0;
for (const column of categoryColumns) {
// 1. 먼저 기존 매핑 모두 삭제
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
tableName: selectedTable,
columnName: column.column_name,
});
try {
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
} catch (error) {
console.error("❌ 기존 매핑 삭제 실패:", error);
}
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) — 직렬 await 대신 Promise.allSettled 병렬 호출
if (column.category_menus && column.category_menus.length > 0) {
for (const menuObjid of column.category_menus) {
try {
console.log("🔄 매핑 API 호출:", {
tableName: selectedTable,
columnName: column.column_name,
menuObjid,
});
const mappingResponse = await createColumnMapping({
const mappingResults = await Promise.allSettled(
column.category_menus.map((menuObjid) =>
createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.column_name,
physicalColumnName: column.column_name,
menuObjid,
description: `${column.display_name} (메뉴별 카테고리)`,
});
console.log("✅ 매핑 API 응답:", mappingResponse);
if (mappingResponse.success) {
totalSuccessCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
totalFailCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
totalFailCount++;
}
}
}),
),
);
const colSuccess = mappingResults.filter(
(r) => r.status === "fulfilled" && r.value.success,
).length;
totalSuccessCount += colSuccess;
totalFailCount += mappingResults.length - colSuccess;
}
}
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
if (totalSuccessCount > 0) {
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
} else if (totalFailCount > 0) {
@@ -936,10 +903,8 @@ export default function TableManagementPage() {
// 테이블 목록 새로고침 (라벨 변경 반영)
loadTables();
// 저장 후 데이터 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable, 1, pageSize);
}, 1000);
// 저장 후 데이터 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
await loadColumnTypes(selectedTable, 1, pageSize);
} else {
showErrorToast("설정 저장에 실패했습니다", response.data.message, {
guidance: "잠시 후 다시 시도해 주세요.",
@@ -983,7 +948,20 @@ export default function TableManagementPage() {
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
);
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) => {
const sa = matchScore(a);
const sb = matchScore(b);
if (sa !== sb) return sa - sb;
const nameA = a.display_name || a.table_name;
const nameB = b.display_name || b.table_name;
const aKo = isKorean(nameA);
@@ -1037,24 +1015,28 @@ export default function TableManagementPage() {
} else {
newPkCols = currentPkCols.filter((c) => c !== columnName);
}
// 이번 세션 동안 묻지 않기로 한 경우 즉시 적용
if (pkSkipConfirmSession) {
applyPkChange(newPkCols);
return;
}
// PK 변경은 확인 다이얼로그 표시
setPendingPkColumns(newPkCols);
setPkDialogOpen(true);
},
[constraints.primaryKey?.columns],
[constraints.primaryKey?.columns, pkSkipConfirmSession],
);
// PK 변경 확인
const handlePkConfirm = async () => {
// PK 변경 실제 적용 (다이얼로그 거치지 않거나 거친 후 호출)
const applyPkChange = async (newPkCols: string[]) => {
if (!selectedTable) return;
try {
if (pendingPkColumns.length === 0) {
if (newPkCols.length === 0) {
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
setPkDialogOpen(false);
return;
}
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
columns: pendingPkColumns,
columns: newPkCols,
});
if (response.data.success) {
toast.success(response.data.message);
@@ -1066,11 +1048,15 @@ export default function TableManagementPage() {
showErrorToast("PK 설정에 실패했습니다", error, {
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
});
} finally {
setPkDialogOpen(false);
}
};
// PK 변경 확인 (다이얼로그에서 호출)
const handlePkConfirm = async () => {
setPkDialogOpen(false);
await applyPkChange(pendingPkColumns);
};
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index", checked: boolean) => {
@@ -1187,6 +1173,37 @@ export default function TableManagementPage() {
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 () => {
if (!tableToDelete) return;
@@ -1385,8 +1402,8 @@ export default function TableManagementPage() {
</div>
</div>
{/* 3패널 메인 */}
<div className="flex flex-1 overflow-hidden">
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
<div className="relative flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
{/* 검색 */}
@@ -1401,7 +1418,7 @@ export default function TableManagementPage() {
/>
</div>
{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">
<Checkbox
checked={
@@ -1458,7 +1475,7 @@ export default function TableManagementPage() {
)}
<div
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
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50",
@@ -1488,13 +1505,13 @@ export default function TableManagementPage() {
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1">
<span className={cn(
"truncate text-[16px] leading-tight",
"truncate text-[13px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.display_name || table.table_name}
</span>
</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}
</div>
</div>
@@ -1551,26 +1568,24 @@ export default function TableManagementPage() {
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
/>
) : (
<div
role="button"
tabIndex={0}
onClick={() => {
setEditingHeaderValue(tableLabel);
setEditingHeaderField("label");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div className="group flex items-center gap-1.5">
<span className="text-[15px] font-bold tracking-tight">
{tableLabel || (
<span className="text-muted-foreground/60">{selectedTable}</span>
)}
</span>
<button
type="button"
onClick={() => {
setEditingHeaderValue(tableLabel);
setEditingHeaderField("label");
}
}}
className="-mx-2 cursor-text rounded px-2 py-0.5 text-[15px] font-bold tracking-tight hover:bg-muted/60 transition-colors"
title="클릭하여 표시명 편집"
>
{tableLabel || (
<span className="text-muted-foreground/60">{selectedTable}</span>
)}
}}
className="text-muted-foreground/50 hover:text-foreground transition-colors"
title="표시명 편집"
aria-label="표시명 편집"
>
<Pencil className="h-3 w-3" />
</button>
</div>
)}
{/* table_name (코드, 편집 불가) */}
@@ -1596,26 +1611,24 @@ export default function TableManagementPage() {
className="mt-1 h-7 -mx-2 px-2 text-xs"
/>
) : (
<div
role="button"
tabIndex={0}
onClick={() => {
setEditingHeaderValue(tableDescription);
setEditingHeaderField("description");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div className="group mt-0.5 flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{tableDescription || (
<span className="text-muted-foreground/50">+ </span>
)}
</span>
<button
type="button"
onClick={() => {
setEditingHeaderValue(tableDescription);
setEditingHeaderField("description");
}
}}
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="클릭하여 설명 편집"
>
{tableDescription || (
<span className="text-muted-foreground/50">+ </span>
)}
}}
className="text-muted-foreground/50 hover:text-foreground transition-colors"
title="설명 편집"
aria-label="설명 편집"
>
<Pencil className="h-2.5 w-2.5" />
</button>
</div>
)}
</div>
@@ -1646,54 +1659,120 @@ export default function TableManagementPage() {
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<>
<TypeOverviewStrip
columns={columns}
activeFilter={typeFilter}
onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
<TabsList
className={cn(
"h-9 w-full shrink-0 justify-start gap-1 rounded-none bg-transparent p-0 px-2 pt-1",
"border-b border-border",
)}
>
<TabsTrigger
value="columns"
className={cn(
"flex flex-none items-center gap-2 rounded-t-md rounded-b-none border border-border bg-transparent -mb-px",
"px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:bg-muted/40 hover:text-foreground",
"data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-semibold",
"data-[state=active]:border-b-card",
"data-[state=active]:shadow-[inset_0_2px_0_hsl(var(--primary))]",
)}
>
<Columns3 className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="references"
className={cn(
"flex flex-none items-center gap-2 rounded-t-md rounded-b-none border border-border bg-transparent -mb-px",
"px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:bg-muted/40 hover:text-foreground",
"data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-semibold",
"data-[state=active]:border-b-card",
"data-[state=active]:shadow-[inset_0_2px_0_hsl(var(--primary))]",
)}
>
<Link2 className="h-4 w-4" />
{(() => {
const refCount = columns.filter((c) =>
["entity", "code", "category", "numbering"].includes(c.input_type),
).length;
return refCount > 0 ? (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[11px]">
{refCount}
</Badge>
) : null;
})()}
</TabsTrigger>
</TabsList>
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
<TypeOverviewStrip
columns={columns}
activeFilter={typeFilter}
onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
}
return;
}
return;
}
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
}
return;
}
return;
const idx = columns.findIndex((c) => c.column_name === columnName);
if (idx >= 0) handleColumnChange(idx, field, value);
}}
constraints={constraints}
typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle}
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
}
const idx = columns.findIndex((c) => c.column_name === columnName);
if (idx >= 0) handleColumnChange(idx, field, value);
}}
constraints={constraints}
typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle}
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
}
tables={tables}
referenceTableColumns={referenceTableColumns}
/>
</>
onDeleteColumn={handleDeleteColumnClick}
tables={tables}
referenceTableColumns={referenceTableColumns}
/>
</TabsContent>
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
<ReferenceListView
columns={columns}
tables={tables}
referenceTableColumns={referenceTableColumns}
selectedColumn={selectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
/>
</TabsContent>
</Tabs>
)}
</>
)}
</div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
<ColumnDetailPanel
{/* :
- (xl ): 3-pane
- 화면: 기존처럼 in */}
<div
className={cn(
"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",
"xl:relative xl:z-0 xl:flex-shrink-0 xl:translate-x-0 xl:pointer-events-auto xl:shadow-none xl:transition-none",
)}
>
<ColumnDetailPanel
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
tables={tables}
referenceTableColumns={referenceTableColumns}
@@ -1705,6 +1784,21 @@ export default function TableManagementPage() {
handleInputTypeChange(selectedColumn, value as string);
return;
}
// 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장
if (field === "is_nullable") {
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
if (currentColumn) {
handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES");
}
return;
}
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
if (currentColumn) {
handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO");
}
return;
}
if (field === "reference_table" && value) {
loadReferenceTableColumns(value as string);
}
@@ -1719,8 +1813,7 @@ export default function TableManagementPage() {
codeInfoOptions={commonCodeOptions}
referenceTableOptions={referenceTableOptions}
/>
</div>
)}
</div>
</div>
{/* DDL 모달 컴포넌트들 */}
@@ -1755,13 +1848,13 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null);
}}
mode={duplicateModalMode}
sourceTableName={duplicateSourceTable || undefined}
source_table_name={duplicateSourceTable || undefined}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
table_name={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)
@@ -1863,6 +1956,62 @@ export default function TableManagementPage() {
</DialogFooter>
</DialogContent>
</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>
</>
)}
@@ -1897,6 +2046,14 @@ export default function TableManagementPage() {
<p className="text-destructive mt-2 text-sm">PK가 </p>
)}
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<Checkbox
checked={pkSkipConfirmSession}
onCheckedChange={(v) => setPkSkipConfirmSession(v === true)}
/>
PK (composite PK )
</label>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@@ -1,5 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isManagementHost } from "@/lib/tenant/subdomain";
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
import { CompanyTable } from "@/components/admin/CompanyTable";
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
*
*/
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 {
// 데이터
companies,
@@ -51,6 +64,8 @@ export default function CompanyPage() {
clearError,
} = useCompanyManagement();
if (hostBlocked) return null;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as XLSX from "xlsx";
import {
ArrowDownToLine,
ArrowUpToLine,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
@@ -12,6 +14,7 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
FileDown,
Folder,
FolderOpen,
FolderTree,
@@ -28,6 +31,7 @@ import {
Upload,
Users,
X,
XCircle,
} from "lucide-react";
import {
DropdownMenu,
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
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 { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
@@ -152,11 +158,15 @@ export default function DeptMngListPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
const [bulkUploading, setBulkUploading] = useState(false);
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
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);
@@ -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
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
@@ -636,14 +891,7 @@ export default function DeptMngListPage() {
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkText("");
setBulkOpen(true);
}}
onClick={openBulkModal}
>
<Upload className="h-3.5 w-3.5" />
@@ -1013,106 +1261,229 @@ export default function DeptMngListPage() {
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
/>
{/* 일괄등록 */}
{/* 일괄등록 / 일괄업데이트 */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-[640px]">
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> / </DialogTitle>
</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>
{/* 일괄등록 실패 결과 */}
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle> ({bulkFailures.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold w-16"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkFailures.map((f, idx) => (
<tr key={idx}>
<td className="px-3 py-1.5 font-mono">{f.line}</td>
<td className="px-3 py-1.5">{f.deptName}</td>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button>
<Tabs
value={bulkTab}
onValueChange={(v) => {
setBulkTab(v as "create" | "update");
resetBulkData();
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mb-2">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="update"></TabsTrigger>
</TabsList>
<TabsContent value="create" className="m-0 space-y-2">
<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>[ 릿] .</li>
<li> [] [].</li>
<li> (DEPT_n).</li>
<li> user_id (,) . 10/role.</li>
<li> 1000 .</li>
</ul>
</div>
</TabsContent>
<TabsContent value="update" className="m-0 space-y-2">
<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>
</DialogContent>
</Dialog>
@@ -10,6 +10,7 @@ import { LayerDefinition } from "@/types/screen-management";
import { useRouter } from "next/navigation";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { initializeComponents } from "@/lib/registry/components";
import { isTableLikeComponent } from "@/lib/utils/componentTypeUtils";
import { EditModal } from "@/components/screen/EditModal";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
@@ -428,10 +429,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
const hasTableWidget = layout.components.some(
(comp: any) =>
comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table",
);
// canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip
const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
if (hasTableWidget) {
return;
+3 -3
View File
@@ -92,10 +92,10 @@ export default function TestCardResponsivePage() {
{/* ── 1. v2-text-display (경량, 항상 동일) ── */}
<div className="mb-2 text-base font-semibold text-slate-800"></div>
{/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */}
{/* ── 2. canonical stats (경량, container-type 만 부착) ── */}
<div
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" },
@@ -214,7 +214,7 @@ export default function TestCardResponsivePage() {
<b className="text-indigo-600">v2-table-search-widget</b> / (CSS @container ).
</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 .
</li>
</ul>
+5 -53
View File
@@ -19,8 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react";
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
import { toast } from "sonner";
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
import { ddlApi } from "../../lib/api/ddl";
@@ -57,8 +56,6 @@ export function CreateTableModal({
const [validating, setValidating] = useState(false);
const [tableNameError, setTableNameError] = useState("");
const [validationResult, setValidationResult] = useState<any>(null);
const [useLogTable, setUseLogTable] = useState(false);
/**
*
*/
@@ -76,7 +73,6 @@ export function CreateTableModal({
]);
setTableNameError("");
setValidationResult(null);
setUseLogTable(false);
};
/**
@@ -107,15 +103,11 @@ export function CreateTableModal({
try {
// 1. 테이블 컬럼 정보 조회
const columnsResponse = await tableManagementApi.getColumnList(tableName);
console.log("🔍 컬럼 조회 응답:", columnsResponse);
if (columnsResponse.success && columnsResponse.data) {
// API는 { columns, total, page, size } 형태로 반환
const columnsList = columnsResponse.data.columns;
console.log("🔍 컬럼 리스트:", columnsList);
if (columnsList && columnsList.length > 0) {
// 첫 번째 컬럼에서 테이블 설명 가져오기 (모든 컬럼이 같은 테이블 설명을 가짐)
const firstColumn = columnsList[0];
@@ -285,23 +277,6 @@ export function CreateTableModal({
if (result.success) {
toast.success(result.message);
// 로그 테이블 생성 옵션이 선택되었다면 로그 테이블 생성
if (useLogTable) {
try {
const pkColumn = { columnName: "id", dataType: "integer" };
const logResult = await tableManagementApi.createLogTable(tableName, pkColumn);
if (logResult.success) {
toast.success(`${tableName}_log 테이블이 생성되었습니다.`);
} else {
toast.warning(`테이블은 생성되었으나 로그 테이블 생성 실패: ${logResult.message}`);
}
} catch (logError) {
toast.warning("테이블은 생성되었으나 로그 테이블 생성 중 오류가 발생했습니다.");
}
}
onSuccess(result);
onClose();
} else {
@@ -322,7 +297,7 @@ export function CreateTableModal({
return (
<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>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
@@ -336,7 +311,7 @@ export function CreateTableModal({
</DialogDescription>
</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="space-y-2">
@@ -380,29 +355,6 @@ export function CreateTableModal({
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
</div>
{/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */}
{/* <div className="flex items-start space-x-3 rounded-lg border p-4">
<Checkbox
id="useLogTable"
checked={useLogTable}
onCheckedChange={(checked) => setUseLogTable(checked as boolean)}
disabled={loading}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="useLogTable"
className="flex cursor-pointer items-center gap-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Activity className="h-4 w-4" />
</label>
<p className="text-muted-foreground text-xs">
<code className="bg-muted rounded px-1 py-0.5">{tableName || "table"}_log</code>
INSERT/UPDATE/DELETE .
</p>
</div>
</div> */}
{/* 자동 추가 컬럼 안내 */}
<Alert>
<Info className="h-4 w-4" />
@@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-base sm:text-lg">
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
</DialogTitle>
</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">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<DialogFooter className="shrink-0 gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
@@ -219,27 +219,27 @@ export function RestApiConnectionList() {
return (
<>
{/* 검색 및 필터 */}
<div className="flex flex-col gap-4 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 shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{/* 검색 */}
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<div className="relative w-full sm:w-[260px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="연결명 또는 URL로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
className="h-8 pl-9 text-xs"
/>
</div>
{/* 인증 타입 필터 */}
<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="인증 타입" />
</SelectTrigger>
<SelectContent>
{supportedAuthTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<SelectItem key={type.value} value={type.value} className="text-xs">
{type.label}
</SelectItem>
))}
@@ -248,12 +248,12 @@ export function RestApiConnectionList() {
{/* 활성 상태 필터 */}
<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="상태" />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
@@ -262,79 +262,79 @@ export function RestApiConnectionList() {
</div>
{/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 연결 목록 */}
{loading ? (
<div className="flex h-64 items-center justify-center bg-card">
<div className="text-sm text-muted-foreground"> ...</div>
<div className="flex h-40 shrink-0 items-center justify-center rounded-lg border bg-card">
<div className="text-xs text-muted-foreground"> ...</div>
</div>
) : 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">
<p className="text-sm text-muted-foreground"> REST API </p>
<p className="text-xs text-muted-foreground"> REST API </p>
</div>
</div>
) : (
<div className="bg-card">
<Table>
<TableHeader>
<TableRow className="bg-background">
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> URL</TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold"></TableHead>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<Table divClassName="flex-1 overflow-auto">
<TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow className="border-b bg-muted hover:bg-muted">
<TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"> URL</TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-9 px-3 text-right text-xs font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((connection) => (
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
<TableCell className="h-16 px-6 py-3 text-sm">
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-10 px-3 text-xs">
<div className="max-w-[200px]">
<div className="truncate font-medium" title={connection.connection_name}>
{connection.connection_name}
</div>
{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}
</div>
)}
</div>
</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}
</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}>
{connection.base_url}
</div>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
<TableCell className="h-10 px-3 text-xs">
<Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
</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}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
<TableCell className="h-10 px-3 text-xs">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<TableCell className="h-10 px-3 text-xs">
{connection.last_test_date ? (
<div>
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
<div className="flex items-center gap-1.5">
<span>{new Date(connection.last_test_date).toLocaleDateString()}</span>
<Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1"
className="text-[10px]"
>
{connection.last_test_result === "Y" ? "성공" : "실패"}
</Badge>
@@ -343,41 +343,41 @@ export function RestApiConnectionList() {
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
<div className="flex items-center gap-2">
<TableCell className="h-10 px-3 text-xs">
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)}
className="h-9 text-sm"
className="h-7 px-2 text-xs"
>
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button>
{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!) ? "성공" : "실패"}
</Badge>
)}
</div>
</TableCell>
<TableCell className="h-16 px-6 py-3 text-right">
<div className="flex justify-end gap-2">
<TableCell className="h-10 px-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
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
variant="ghost"
size="icon"
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>
</div>
</TableCell>
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
return n;
}, [column]);
if (!column) return null;
const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length
const rawSource = referenceTableOptions.length
? [...referenceTableOptions]
: [
{ 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 rest = raw.filter((o) => o.value !== "none");
@@ -106,6 +112,31 @@ export function ColumnDetailPanel({
return noneOpt ? [noneOpt, ...rest] : rest;
}, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
if (!column) {
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* in column=null
stuck X */}
<div className="flex flex-shrink-0 items-center justify-end px-4 py-3">
<Button variant="ghost" size="icon" onClick={onClose} aria-label="닫기" className="h-7 w-7">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<div className="rounded-full bg-muted/60 p-4">
<Settings2 className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="mt-4 text-sm font-medium text-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */}
@@ -173,12 +204,12 @@ export function ColumnDetailPanel({
isLegacy && "cursor-not-allowed",
)}
>
<span className={cn(
"text-base font-bold leading-none",
isSelected ? "text-primary" : conf.color,
)}>
{conf.iconChar}
</span>
<conf.Icon
className={cn(
"h-4 w-4",
isSelected ? "text-primary" : conf.color,
)}
/>
<span className={cn(
"text-[16px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground",
@@ -372,7 +403,10 @@ export function ColumnDetailPanel({
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<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}>
{opt.label}
</SelectItem>
@@ -1,9 +1,15 @@
"use client";
import React, { useMemo } from "react";
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
import { MoreHorizontal, Database, FileStack, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void;
onDeleteColumn?: (columnName: string) => void;
/** 호버 시 한글 라벨 표시용 (Badge title) */
tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
@@ -40,11 +47,10 @@ function getIndexState(
return { isPk, hasIndex };
}
/** 그룹 헤더 라벨 */
const GROUP_LABELS: Record<string, { icon: React.FC<{ className?: string }>; label: string }> = {
basic: { icon: FileStack, label: "기본 정보" },
reference: { icon: Layers, label: "참조 정보" },
meta: { icon: Database, label: "메타 정보" },
/** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */
const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = {
user: { icon: FileStack, label: "사용자 컬럼" },
system: { icon: Database, label: "시스템 컬럼" },
};
export function ColumnGrid({
@@ -57,6 +63,7 @@ export function ColumnGrid({
getColumnIndexState: externalGetIndexState,
onPkToggle,
onIndexToggle,
onDeleteColumn,
tables,
referenceTableColumns,
}: ColumnGridProps) {
@@ -65,30 +72,28 @@ export function ColumnGrid({
[constraints, externalGetIndexState],
);
/** typeFilter 적용 후 그룹정렬 */
/** typeFilter 적용 후 사용자/시스템 그룹분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */
const filteredAndGrouped = useMemo(() => {
const filtered =
typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns;
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
const groups = { user: [] as ColumnTypeInfo[], system: [] as ColumnTypeInfo[] };
for (const col of filtered) {
const group = getColumnGroup(col);
groups[group].push(col);
const g = getColumnGroup(col) === "meta" ? "system" : "user";
groups[g].push(col);
}
return groups;
}, [columns, typeFilter]);
const totalFiltered =
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length;
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
>
<span />
<span> · </span>
<span>/</span>
<span></span>
<span className="text-center">PK / NN / IDX / UQ</span>
<span />
@@ -100,7 +105,7 @@ export function ColumnGrid({
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
</div>
) : (
(["basic", "reference", "meta"] as const).map((groupKey) => {
(["user", "system"] as const).map((groupKey) => {
const list = filteredAndGrouped[groupKey];
if (list.length === 0) return null;
const { icon: Icon, label } = GROUP_LABELS[groupKey];
@@ -134,7 +139,7 @@ export function ColumnGrid({
}}
className={cn(
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
"grid-cols-[4px_1fr_100px_160px_40px]",
"bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
@@ -144,73 +149,13 @@ export function ColumnGrid({
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
<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.column_name})`
: column.column_name}
</div>
</div>
{/* 참조/설정 칩 */}
<div className="flex min-w-0 flex-wrap gap-1">
{column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && (
<>
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
const t = tables.find((tb) => tb.table_name === column.reference_table);
return t?.display_name && t.display_name !== t.table_name
? `${t.display_name} (${column.reference_table})`
: column.reference_table;
})()
: column.reference_table
}
>
{column.reference_table}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[column.reference_table]
? (() => {
const refCols = referenceTableColumns[column.reference_table];
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
return c?.display_name && c.display_name !== c.column_name
? `${c.display_name} (${column.reference_column})`
: column.reference_column ?? "—";
})()
: column.reference_column ?? "—"
}
>
{column.reference_column || "—"}
</Badge>
</>
)}
{column.input_type === "code" && (
<span className="text-muted-foreground truncate text-xs">
{column.code_info ?? "—"} · {column.default_value ?? ""}
</span>
)}
{column.input_type === "numbering" && column.numbering_rule_id && (
<Badge variant="outline" className="text-xs font-normal">
{column.numbering_rule_id}
</Badge>
)}
{column.input_type !== "entity" &&
column.input_type !== "code" &&
column.input_type !== "numbering" &&
(column.default_value ? (
<span className="text-muted-foreground truncate text-xs">{column.default_value}</span>
) : (
<span className="text-muted-foreground/60 text-xs"></span>
))}
</div>
{/* 타입 뱃지 */}
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
@@ -285,20 +230,37 @@ export function ColumnGrid({
</button>
</div>
<div className="flex items-center justify-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onSelectColumn(column.column_name);
}}
aria-label="상세 설정"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
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>
);
@@ -0,0 +1,223 @@
"use client";
import React, { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Database, FolderTree, Hash, Link2, FileCode2 } from "lucide-react";
import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
export interface ReferenceListViewProps {
columns: ColumnTypeInfo[];
tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
onSelectColumn?: (columnName: string) => void;
selectedColumn?: string | null;
}
type RefKind = "entity" | "code" | "category" | "numbering";
const KIND_META: Record<
RefKind,
{ icon: React.FC<{ className?: string }>; label: string; color: string; bgColor: string }
> = {
entity: { icon: Link2, label: "테이블 참조", color: "text-violet-600", bgColor: "bg-violet-50" },
code: { icon: FileCode2, label: "공통코드", color: "text-emerald-600", bgColor: "bg-emerald-50" },
category: { icon: FolderTree, label: "카테고리", color: "text-teal-600", bgColor: "bg-teal-50" },
numbering: { icon: Hash, label: "채번", color: "text-orange-600", bgColor: "bg-orange-50" },
};
function getRefKind(col: ColumnTypeInfo): RefKind | null {
const t = col.input_type;
if (t === "entity" || t === "code" || t === "category" || t === "numbering") return t;
return null;
}
export function ReferenceListView({
columns,
tables,
referenceTableColumns,
onSelectColumn,
selectedColumn = null,
}: ReferenceListViewProps) {
const grouped = useMemo(() => {
const groups: Record<RefKind, ColumnTypeInfo[]> = {
entity: [],
code: [],
category: [],
numbering: [],
};
for (const col of columns) {
const kind = getRefKind(col);
if (kind) groups[kind].push(col);
}
return groups;
}, [columns]);
const totalRefs =
grouped.entity.length + grouped.code.length + grouped.category.length + grouped.numbering.length;
if (totalRefs === 0) {
return (
<div className="flex flex-1 items-center justify-center py-12 text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 text-muted-foreground/50" />
<span> .</span>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 헤더 */}
<div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
>
<span />
<span> </span>
<span> </span>
<span> </span>
</div>
{/* 그룹별 행 */}
<div className="flex-1 overflow-y-auto">
{(["entity", "code", "category", "numbering"] as const).map((kind) => {
const list = grouped[kind];
if (list.length === 0) return null;
const meta = KIND_META[kind];
const KindIcon = meta.icon;
return (
<div key={kind} className="space-y-1 py-2">
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
<KindIcon className={cn("h-4 w-4", meta.color)} />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{meta.label}
</span>
<Badge variant="secondary" className="text-xs">
{list.length}
</Badge>
</div>
{list.map((column) => {
const typeConf = INPUT_TYPE_COLORS[column.input_type || "text"] || INPUT_TYPE_COLORS.text;
const isSelected = selectedColumn === column.column_name;
return (
<div
key={column.column_name}
role="button"
tabIndex={0}
onClick={() => onSelectColumn?.(column.column_name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectColumn?.(column.column_name);
}
}}
className={cn(
"grid min-h-10 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
>
{/* 색상바 */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 소스 컬럼명 */}
<div className="min-w-0">
<div className="truncate text-xs font-medium">
{column.display_name && column.display_name !== column.column_name
? `${column.display_name} (${column.column_name})`
: column.column_name}
</div>
</div>
{/* 참조 종류 칩 */}
<div className={cn("inline-flex w-fit items-center gap-1 rounded-md border px-2 py-0.5 text-xs", meta.bgColor, meta.color)}>
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-70" />
{meta.label}
</div>
{/* 참조 대상 */}
<div className="flex min-w-0 flex-wrap items-center gap-1">
{kind === "entity" && column.reference_table && column.reference_table !== "none" ? (
<>
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
const t = tables.find((tb) => tb.table_name === column.reference_table);
return t?.display_name && t.display_name !== t.table_name
? `${t.display_name} (${column.reference_table})`
: column.reference_table;
})()
: column.reference_table
}
>
{column.reference_table}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[column.reference_table]
? (() => {
const refCols = referenceTableColumns[column.reference_table];
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
return c?.display_name && c.display_name !== c.column_name
? `${c.display_name} (${column.reference_column})`
: column.reference_column ?? "—";
})()
: column.reference_column ?? "—"
}
>
{column.reference_column || "—"}
</Badge>
</>
) : kind === "code" ? (
column.code_info ? (
<Badge variant="outline" className="text-xs font-normal">
: {column.code_info}
</Badge>
) : (
<span className="text-muted-foreground/60 text-xs"> ( )</span>
)
) : kind === "category" ? (
column.category_ref ? (
<Badge variant="outline" className="text-xs font-normal">
: {column.category_ref}
</Badge>
) : column.category_menus && column.category_menus.length > 0 ? (
<Badge variant="outline" className="text-xs font-normal">
{column.category_menus.length}
</Badge>
) : (
<span className="text-muted-foreground/60 text-xs"> ( )</span>
)
) : kind === "numbering" ? (
column.numbering_rule_id ? (
<Badge variant="outline" className="text-xs font-normal">
: {column.numbering_rule_id}
</Badge>
) : (
<span className="text-muted-foreground/60 text-xs"> ( )</span>
)
) : (
<span className="text-muted-foreground/60 text-xs"></span>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}
@@ -3,7 +3,7 @@
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types";
import { INPUT_TYPE_COLORS, FALLBACK_TYPE_CONFIG } from "./types";
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
export interface TypeOverviewStripProps {
@@ -57,20 +57,13 @@ export function TypeOverviewStrip({
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100;
let offset = 0;
const LEGACY_CONF = {
color: "text-amber-600",
bgColor: "bg-amber-50",
barColor: "bg-amber-400",
label: "Legacy",
desc: "구버전 타입",
iconChar: "?",
};
const LEGACY_CONF = { ...FALLBACK_TYPE_CONFIG, color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-400" };
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset;
offset += length;
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || FALLBACK_TYPE_CONFIG);
return {
type,
dashArray,
@@ -112,7 +105,7 @@ export function TypeOverviewStrip({
.filter((type) => (counts[type] || 0) > 0)
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
.map((type) => {
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
const conf = INPUT_TYPE_COLORS[type] || { ...FALLBACK_TYPE_CONFIG, label: type };
const isActive = activeFilter === null || activeFilter === type;
return (
<button
+58 -17
View File
@@ -3,6 +3,23 @@
* page.tsx에서 /
*/
import {
AlignLeft,
Braces,
Calendar,
CheckSquare,
ChevronDown,
CircleDot,
FolderTree,
Hash,
HelpCircle,
Image as ImageIcon,
Link2,
ListOrdered,
Paperclip,
Type,
type LucideIcon,
} from "lucide-react";
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
export interface TableInfo {
@@ -52,24 +69,34 @@ export interface TypeColorConfig {
barColor: string;
label: string;
desc: string;
iconChar: string;
Icon: LucideIcon;
}
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
/** Legacy/알 수 없는 타입용 fallback config */
export const FALLBACK_TYPE_CONFIG: TypeColorConfig = {
color: "text-muted-foreground",
bgColor: "bg-muted",
barColor: "bg-muted",
label: "Legacy",
desc: "구버전 타입",
Icon: HelpCircle,
};
/** 입력 타입별 색상 맵 - Icon 은 lucide 컴포넌트로 통일 (letter/symbol/emoji 혼재 방지) */
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", Icon: Type },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", Icon: Hash },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", Icon: Calendar },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", Icon: Braces },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", Icon: Link2 },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", Icon: ChevronDown },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", Icon: CheckSquare },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", Icon: ListOrdered },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", Icon: FolderTree },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", Icon: AlignLeft },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", Icon: CircleDot },
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", Icon: Paperclip },
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", Icon: ImageIcon },
};
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
@@ -81,9 +108,23 @@ export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDE
{} as Record<string, TypeColorConfig>,
);
/** 컬럼 그룹 판별 */
/** 컬럼 그룹 판별 — 시스템 자동 생성 컬럼은 meta 로 분류 (사용자가 거의 수정하지 않으므로 시각 분리) */
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
// 시스템 컬럼: invyone 자동 생성 (id/날짜/작성자/회사) 외에 VEX 계승 (objid), 멀티테넌트 (tenant_id),
// 수정자/생성자 변형 (creator/modifier/created_at/updated_at) 까지 모두 포함
const metaCols = [
"id",
"objid",
"tenant_id",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
"creator",
"modifier",
"company_code",
];
if (metaCols.includes(col.column_name)) return "meta";
if (["entity", "code", "category"].includes(col.input_type)) return "reference";
return "basic";
@@ -92,6 +92,11 @@ export function ResponsiveDataView<T>({
}: ResponsiveDataViewProps<T>) {
const rowHeight = compact ? "h-10" : "h-16";
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에서 자동 생성
function resolveCardFields(item: T): RDVCardField<T>[] {
if (typeof cardFields === "function") return cardFields(item);
@@ -233,16 +238,20 @@ export function ResponsiveDataView<T>({
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
<div
className={cn(
"hidden rounded-lg border bg-card shadow-sm @3xl:block",
// scrollContainer 모드: 부모 flex 안에서 가용 height 다 차지 + 자체 세로 스크롤 + sticky 헤더
scrollContainer && "min-h-0 flex-1 overflow-y-auto overflow-x-auto",
// scrollContainer 모드는 flex 컨테이너로, 아니면 block 으로 표시 (둘 다 < @3xl 에서는 hidden)
scrollContainer
? "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
)}
>
<Table>
<Table divClassName={scrollContainer ? "flex-1 overflow-auto" : undefined}>
<TableHeader
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">
@@ -250,7 +259,7 @@ export function ResponsiveDataView<T>({
<TableHead
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={cn(headHeight, "text-sm font-semibold")}
className={cn(headHeight, cellPad, headText, "font-semibold")}
>
{col.label}
</TableHead>
@@ -258,7 +267,7 @@ export function ResponsiveDataView<T>({
{renderActions && (
<TableHead
style={{ width: actionsWidth || "120px" }}
className={cn(headHeight, "text-sm font-semibold")}
className={cn(headHeight, cellPad, headText, "font-semibold")}
>
{actionsLabel || "작업"}
</TableHead>
@@ -278,7 +287,7 @@ export function ResponsiveDataView<T>({
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(rowHeight, "text-sm", col.className)}
className={cn(rowHeight, cellPad, bodyText, col.className)}
>
{col.render
? col.render(getNestedValue(item, col.key), item, index)
@@ -286,7 +295,7 @@ export function ResponsiveDataView<T>({
</TableCell>
))}
{renderActions && (
<TableCell className={cn(rowHeight, "text-sm")}>
<TableCell className={cn(rowHeight, cellPad, bodyText)}>
<div className="flex gap-2">{renderActions(item)}</div>
</TableCell>
)}
@@ -319,11 +328,11 @@ export function ResponsiveDataView<T>({
{/* 카드 헤더 */}
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold">
<h3 className={cn("truncate font-semibold", cardTitleClass)}>
{cardTitle(item)}
</h3>
{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)}
</p>
)}
@@ -337,7 +346,7 @@ export function ResponsiveDataView<T>({
{fields.length > 0 && (
<div className="space-y-1.5 border-t pt-3">
{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">
{field.label}
</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 {
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 클래스 + 마커 */
@@ -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';
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { MousePointerClick } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode';
import { ControlToolbar } from './ControlToolbar';
import { ControlPalette } from './ControlPalette';
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';
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) {
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;
return (
<>
{/* 제어 모드 툴바 */}
<ControlToolbar dashboardId={dashboardId} />
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */}
{mode === 'view' && (
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
{/* 카드 미선택 — FlowViewer (호버 토폴로지) + 안내 칩 + FAB */}
{!selectedCard && (
<>
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
<div className="ctrl-mode-hint">
<MousePointerClick size={13} style={{ color: 'rgb(0, 154, 150)' }} />
<span> <b>Control IDE</b> </span>
</div>
<CtrlFab onExit={toggleControlMode} />
</>
)}
{/* 편집 모드: 규칙 빌더 */}
{mode === 'edit' && (
<RuleBuilder canvasRef={canvasRef} />
{/* 카드 선택 — IDE 5-분할 takeover */}
{selectedCard && (
<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>
)}
</>
);
}
/**
* wrapper
*/
/** 호환성 stub — 외부에서 이름으로만 import 하는 경우 */
export function ControlPaletteWrapper() {
const { active, mode, addRuleNode } = useControlMode();
if (!active || mode !== 'edit') return null;
return (
<ControlPalette
onDropTable={() => {}}
onDropControl={() => {}}
/>
);
return null;
}
+81 -37
View File
@@ -2,6 +2,7 @@
import { useRef, useCallback } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getNodeIcon } from './schemas';
import { PortHandle } from './PortHandle';
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) {
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode();
const { removeRuleNode, moveRuleNode, setConfigNodeId, configNodeId } = useControlMode();
const nodeRef = useRef<HTMLDivElement>(null);
const def = CTRL_NODE_TYPES[node.type];
if (!def) return null;
const rgb = def.rgb;
const Ic = getNodeIcon(node.type);
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.stopPropagation();
const sx = e.clientX, sy = e.clientY;
const sl = node.x, st = node.y;
const el = nodeRef.current;
if (el) el.style.zIndex = '30';
let moved = false;
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 = () => {
if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', mv);
document.removeEventListener('mouseup', up);
if (!moved) setConfigNodeId(node.id === configNodeId ? null : node.id);
};
document.addEventListener('mousemove', mv);
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 (
<div
ref={nodeRef}
className="ctrl-action-node"
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
data-node-id={node.id}
data-node-type={node.type}
onMouseDown={handleNodeMouseDown}
style={{
left: node.x,
top: node.y,
['--na-rgb' as string]: def.rgb,
left: node.x, top: node.y,
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)',
}}
>
{/* Input 포트 */}
<PortHandle
nodeId={node.id}
port="in"
type="in"
onDragEnd={onDragEnd}
/>
{/* cat-color stripe */}
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
{/* 헤더 */}
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}>
<div className="ctrl-an-icon">{def.icon}</div>
<span className="ctrl-an-name">{def.label}</span>
<button
className="ctrl-an-del"
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
>
</button>
</div>
{/* 본문 */}
<div
className="ctrl-an-body"
onClick={() => setConfigNodeId(node.id)}
>
<div className="ctrl-an-summary">
{node.config?.summary || '클릭하여 설정'}
{/* 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>
<button
type="button"
className="ctrl-an-del"
title="삭제"
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
style={{ marginLeft: 'auto' }}
>
</button>
</div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</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">
{outPorts.map((p) => (
<PortHandle
@@ -93,7 +138,6 @@ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps)
port={p.port}
type="out"
cls={p.cls}
label={p.label}
onDragStart={onDragStart}
/>
))}
+190 -52
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { CTRL_NODE_TYPES } from './hooks/useControlMode';
import { useEffect, useMemo, useState } from 'react';
import { Search, Star } from 'lucide-react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta';
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 [search, setSearch] = useState('');
const mode = useControlMode((s) => s.mode);
const isEditMode = mode === 'edit';
useEffect(() => {
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>) => {
e.dataTransfer.setData('text/plain', JSON.stringify(data));
e.dataTransfer.effectAllowed = 'copy';
};
const catLabels: Record<string, string> = {
'트리거': '트리거',
'조건': '조건 / 분기',
'액션': '액션',
'흐름': '흐름 제어',
'연동': '외부 연동',
'기록': '기록',
: '트리거',
: '조건 / 분기',
: '액션',
: '흐름 제어',
: '외부 연동',
: '기록',
};
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
return (
<div style={{ overflowY: 'auto', flex: 1 }}>
{/* DB 테이블 섹션 */}
<div className="ctrl-palette-section">DB </div>
{tables.map((t) => {
const name = t.table_name ?? t.TABLE_NAME;
const label = t.table_label ?? t.TABLE_LABEL ?? name;
return (
<div
key={name}
className="ctrl-palette-item"
draggable
title={`${label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
>
<span className="cp-icon">🏢</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
</div>
);
})}
const renderTableItem = (t: Record<string, any>, isRecommended: boolean) => {
const name = t.table_name ?? t.TABLE_NAME;
const rawLabel = t.table_label ?? t.TABLE_LABEL;
const label = rawLabel && rawLabel !== name ? rawLabel : null;
const icon = pickIcon(String(name));
return (
<div
key={name}
className={`ctrl-palette-item${isRecommended ? ' ctrl-palette-item-rec' : ''}`}
draggable
title={`${label ?? name}${label ? ` (${name})` : ''} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
>
<span className="cp-icon">{icon}</span>
<span className="cp-label">
<span className="cp-label-main">{label ?? name}</span>
{label && <span className="cp-label-sub">{name}</span>}
</span>
{isRecommended && <Star size={9} className="cp-rec-star" />}
</div>
);
};
{/* 제어 노드 — 카테고리별 그룹 */}
{cats.map((cat) => {
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat);
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>{def.label}</span>
</div>
))}
</div>
);
})}
return (
<div className="ctrl-palette">
{/* 헤더 */}
<div className="ctrl-palette-header">
<span className="ctrl-palette-header-title"> </span>
{!isEditMode && (
<span className="ctrl-palette-header-hint"> </span>
)}
</div>
{/* 검색박스 */}
<div className="ctrl-palette-search-wrap">
<Search size={11} className="ctrl-palette-search-icon" />
<input
type="text"
className="ctrl-palette-search"
placeholder="테이블 / 노드 검색…"
value={search}
onChange={(e) => setSearch(e.target.value)}
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>
);
}
+5 -1
View File
@@ -79,6 +79,7 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
flowEdges,
tablePositions,
setActiveFlowCard,
setSelectedCardId,
setFlowEdges,
setTablePositions,
} = useControlMode();
@@ -90,14 +91,17 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// 카드 클릭 → 흐름 표시
// 카드 클릭 → 흐름 표시 + 카드 선택 (selectedCardId 동기화)
const handleCardClick = useCallback(async (cardId: string) => {
// 같은 카드 클릭 → 닫기
if (activeFlowCardId === cardId) {
clearFlow();
setSelectedCardId(null);
return;
}
setSelectedCardId(cardId);
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return;
+403 -50
View File
@@ -1,20 +1,37 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
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() {
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode();
const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode();
const popRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : 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(() => {
if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true));
@@ -23,12 +40,14 @@ export function NodeConfigPopover() {
}
}, [configNodeId, node]);
// 외부 클릭 닫기
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!configNodeId) return;
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return;
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return;
const t = e.target as HTMLElement;
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);
};
document.addEventListener('click', handler);
@@ -49,52 +68,291 @@ export function NodeConfigPopover() {
style={{ left: node.x + 172, top: node.y }}
>
<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>
);
}
function ConfigForm({ type, config, onSave, onClose }: {
type: string; config: Record<string, any>;
/* ─── Helpers ─── */
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;
onClose: () => void;
}) {
const [vals, setVals] = useState<Record<string, any>>(config);
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]);
const handleSave = () => {
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) {
case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`;
break;
case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`;
break;
case 'auto-insert':
summary = `${vals.table || '?'} INSERT`;
summary = `${tLabel(vals.table)} INSERT`;
break;
case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break;
case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break;
case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`;
break;
case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`;
break;
case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break;
case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`;
break;
case 'log':
summary = `로그: ${vals.content || '?'}`;
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:
summary = vals.summary || '설정됨';
}
@@ -103,7 +361,7 @@ function ConfigForm({ type, config, onSave, onClose }: {
return (
<>
{renderFields(type, vals, set)}
{renderFields(type, vals, set, connectedTables)}
<div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button>
@@ -115,21 +373,25 @@ function ConfigForm({ type, config, onSave, onClose }: {
function renderFields(
type: string,
vals: Record<string, any>,
set: (k: string, v: any) => void
set: (k: string, v: any) => void,
tables: Record<string, any>[],
) {
switch (type) {
/* ─── Phase 2 schema-driven 4종 ─── */
case 'condition':
return (
<>
<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 label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} />
</CfgSec>
<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>
</>
);
@@ -137,27 +399,61 @@ function renderFields(
return (
<>
<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 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 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>
</>
);
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':
return (
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
);
case 'timer':
return (
<>
<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 label="경과 기준">
<div style={{ display: 'flex', gap: '.3rem' }}>
@@ -196,20 +492,6 @@ function renderFields(
</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':
return (
<>
@@ -222,22 +504,91 @@ function renderFields(
</CfgSec>
</>
);
case 'validation':
case 'log':
return (
<>
<CfgSec label="대상 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
<CfgSec label="로그 레벨">
<CfgSelect value={vals.level ?? 'info'} onChange={(v) => set('level', v)}
options={['info', 'warn', 'error', 'debug']} />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
</CfgSec>
</>
);
case 'log':
case 'delete':
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>
);
default:
@@ -245,6 +596,8 @@ function renderFields(
}
}
/* ─── 공통 atoms ─── */
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return (
<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) {
// 단일 동그라미가 mousedown(연결 시작) + mouseup(연결 종료) 둘 다 받음
// (테이블 컬럼 port 처럼 시각적으로 하나만 보이는 경우)
const handleMouseDown = (e: React.MouseEvent) => {
if (type !== 'out' || !onDragStart) return;
if (!onDragStart) return;
e.preventDefault();
e.stopPropagation();
onDragStart(nodeId, port, e);
};
const handleMouseUp = (e: React.MouseEvent) => {
if (type !== 'in' || !onDragEnd) return;
if (!onDragEnd) return;
e.stopPropagation();
onDragEnd(nodeId, port);
};
+81 -34
View File
@@ -56,7 +56,7 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
} else {
try {
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;
} 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);
if (!node) return null;
if (node.type === 'table') {
if (port === 'in') return { x: node.x, y: node.y + 18 };
return { x: node.x + 200, y: node.y + 18 };
// 테이블 단위 단일 port — 카드 좌측(in) / 우측(out) 중앙
// (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]);
return (
<>
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
<div
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
<div
className="rule-builder-canvas"
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 연결선 SVG */}
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
@@ -137,32 +142,76 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
</defs>
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null;
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes'
: c.from_port === 'no' ? 'rule-conn-path conn-no'
: 'rule-conn-path';
const marker = c.from_port === 'yes' ? 'url(#arr-yes)'
: c.from_port === 'no' ? 'url(#arr-no)'
: 'url(#arr-rule)';
// Phase 3: edge_type 별 stroke 분기 (yes/no 우선, 그 다음 edge_type)
const portCls = c.from_port === 'yes' ? 'conn-yes'
: c.from_port === 'no' ? 'conn-no' : '';
const edgeCls = c.edge_type ? `edge-${c.edge_type}` : '';
const cls = ['rule-conn-path', portCls, edgeCls].filter(Boolean).join(' ');
// 선 중간 라벨 — 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 (
<path
key={c.id}
d={bezierPath(f.x, f.y, t.x, t.y)}
className={cls}
markerEnd={marker}
/>
<g key={c.id}>
<path d={bezierPath(f.x, f.y, t.x, t.y)} className={cls} />
{portLabel && (
<g transform={`translate(${mx}, ${my - 11})`}>
<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>
{/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null;
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}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
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>
);
}
@@ -221,6 +268,6 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
{/* 설정 팝오버 */}
<NodeConfigPopover />
</>
</div>
);
}
+82 -30
View File
@@ -1,31 +1,56 @@
'use client';
import { useRef, useCallback } from 'react';
import { Database, X } from 'lucide-react';
import { PortHandle } from './PortHandle';
import { useControlMode } from './hooks/useControlMode';
interface TableNodeProps {
tableName: string;
label: string;
icon: string;
/** 호환용 — 더 이상 사용 X (V3 컴팩트로 갈아엎으면서 이모지 폐기, Lucide Database 아이콘 고정) */
icon?: string;
columns: Record<string, any>[];
x: number;
y: number;
style?: React.CSSProperties;
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 removeRuleNode = useControlMode((s) => s.removeRuleNode);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!onMove) return;
const target = e.target as HTMLElement;
if (target.closest('.ctrl-io-port, button')) return;
e.preventDefault();
e.stopPropagation();
const sx = e.clientX, sy = e.clientY;
const sl = x, st = y;
const el = nodeRef.current;
if (el) el.style.zIndex = '30';
let moved = false;
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 = () => {
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);
}, [onMove, tableName, x, y]);
const dtypeIcons: Record<string, string> = {
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡',
textarea: 'Aa', datetime: '📅', entity: '🔗',
};
// stats
const totalCols = columns?.length ?? 0;
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 (
<div
ref={nodeRef}
className="tbl-node"
className="tbl-node tbl-node-compact"
data-table={tableName}
data-node-id={nodeId}
onMouseDown={handleMouseDown}
style={{ left: x, top: y, ...style }}
>
<div className="tbl-node-head" onMouseDown={handleMouseDown}>
<div className="tbl-icon">{icon}</div>
<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';
{/* cyan top stripe (V3RuleNode cat-stripe 와 일관) */}
<div className="tbl-node-stripe" />
return (
<div key={name} className="tbl-col" data-col={name}>
<div className={`tbl-port ${portCls}`} />
<span className="tbl-col-name">{displayName}</span>
<span className="tbl-col-type">{dtIcon} {type}</span>
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>}
</div>
);
})}
<div className="tbl-node-head">
<div className="tbl-node-ico"><Database size={11} /></div>
<div className="tbl-node-title">
<div className="tbl-node-label">{hasKoLabel ? label : tableName}</div>
{hasKoLabel && <div className="tbl-node-sub">{tableName}</div>}
</div>
{nodeId && (
<button
type="button"
className="tbl-node-del"
title="삭제"
onClick={(e) => { e.stopPropagation(); removeRuleNode(nodeId); }}
>
<X size={10} />
</button>
)}
</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>
);
}
@@ -34,9 +34,11 @@ export const CTRL_NODE_TYPES: Record<string, {
interface ControlModeState {
/** 제어 모드 활성 여부 */
active: boolean;
/** 읽기 / 편집 모드 */
mode: 'view' | 'edit';
/** 활성 흐름 — 클릭된 카드 ID */
/** 읽기 / 편집 / 실행 / 이력 모드 (선택된 카드 컨텍스트 안의 토글, v3 — IDE 4-segmented tabs) */
mode: 'view' | 'edit' | 'run' | 'history';
/** 선택된 카드 ID — 카드 클릭 시 좌측 축소 + 그 옆에 제어 패널 */
selectedCardId: string | null;
/** 활성 흐름 — FlowViewer 내부 상태 (selectedCardId 와 동기화) */
activeFlowCardId: string | null;
/** 흐름 엣지 배열 (BFS 결과) */
flowEdges: Record<string, any>[];
@@ -55,7 +57,8 @@ interface ControlModeState {
// 액션
toggleControlMode: () => void;
setMode: (mode: 'view' | 'edit') => void;
setMode: (mode: 'view' | 'edit' | 'run' | 'history') => void;
setSelectedCardId: (cardId: string | null) => void;
setActiveFlowCard: (cardId: string | null) => void;
setFlowEdges: (edges: Record<string, any>[]) => void;
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
@@ -82,6 +85,7 @@ export const useControlMode = create<ControlModeState>()(
(set) => ({
active: false,
mode: 'view',
selectedCardId: null,
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
@@ -94,14 +98,29 @@ export const useControlMode = create<ControlModeState>()(
set((s) => ({
active: !s.active,
mode: 'view',
selectedCardId: null,
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
ruleNodes: [],
ruleConnections: [],
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 }),
setFlowEdges: (edges) => set({ flowEdges: edges }),
@@ -152,6 +171,7 @@ export const useControlMode = create<ControlModeState>()(
set({
active: false,
mode: 'view',
selectedCardId: null,
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
@@ -59,24 +59,58 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
cleanup();
return;
}
// 중복 방지
if (ruleConnections.find((c) =>
// ★ [HIGH] port direction validation — output → output 역방향 엣지 차단
// 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
)) {
cleanup();
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({
id: genConnId(),
from_node_id: d.fromNodeId,
from_port: d.fromPort,
to_node_id: toNodeId,
to_port: toPort,
edge_type: edgeType,
});
cleanup();
}, [addRuleConnection, ruleConnections]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [addRuleConnection]);
const cleanup = useCallback(() => {
const d = dragRef.current;
@@ -89,6 +123,8 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
}, [canvasRef]);
// 마우스 이동/종료 전역 핸들러
// ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출
// (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패)
useEffect(() => {
const onMove = (e: MouseEvent) => {
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 y2 = e.clientY - cr.top + cv.scrollTop;
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 = () => {
if (dragRef.current) cleanup();
const onUp = (e: MouseEvent) => {
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);
@@ -111,7 +180,7 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [canvasRef, cleanup]);
}, [canvasRef, cleanup, 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 type { BlockV2, CanvasV2 } from '@/types/invyone-component';
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
import { isTableLikeComponentType } from '@/lib/utils/componentTypeUtils';
// side-effect: 컴포넌트 레지스트리 등록
import '@/lib/registry/components';
import type { TemplateRenderContext, ViewKey } from './TemplateRenderer';
@@ -68,7 +69,10 @@ export function BlockRenderer({
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) {
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">
+5 -9
View File
@@ -15,7 +15,7 @@ import { DashboardCanvas } from './DashboardCanvas';
import { TemplateLibraryModal } from './TemplateLibraryModal';
import { CardSettingsPanel } from './CardSettingsPanel';
import { ControlMode } from '@/components/control/ControlMode';
import { ControlPalette } from '@/components/control/ControlPalette';
// ControlPalette 는 ControlMode 의 IDE LeftRail 안에서만 사용됨 (외부 사이드바 교체 폐기)
import { useControlMode } from '@/components/control/hooks/useControlMode';
import { useMenu } from '@/contexts/MenuContext';
import { toast } from 'sonner';
@@ -45,7 +45,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
} = useDashboardStore();
const controlActive = useControlMode((s) => s.active);
const controlMode = useControlMode((s) => s.mode);
// controlMode 는 ControlMode 내부에서만 참조 (외부 사이드바 분기 폐기)
const { refreshMenus } = useMenu();
const isSingleMode = !!singleDashboardId;
@@ -243,13 +243,8 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
return (
<div className="dash-shell">
{/* AppLayout .
. */}
{controlActive && controlMode === 'edit' ? (
<div className="dash-side">
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}> </div>
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
</div>
) : !isSingleMode ? (
takeover ControlMode IDE LeftRail (v3 V3Takeover) X */}
{!isSingleMode && !controlActive ? (
<DashboardSidebar
onAddDashboard={handleAddDashboard}
onRenameDashboard={handleRenameDashboard}
@@ -257,6 +252,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
onSwitchDashboard={handleSwitchDashboard}
/>
) : null}
{/* 제어 모드 ON 이지만 카드 미선택 상태에서는 사이드바 자체를 숨김 (IDE 가 화면 takeover 할 자리 확보) */}
<div className="dash-content">
{activeDashboardId ? (
<>
+7
View File
@@ -73,6 +73,9 @@ import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
import { getIconComponent } from "@/components/admin/MenuIconPicker";
import { animatedThemeChange } from "@/lib/themeTransition";
// MANAGEMENT_ONLY_MENU_URLS — DB 컬럼 IS_SOLUTION_ONLY 로 이전 (PR #D).
// 백엔드 /api/admin/user-menus 가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 Set 불필요.
interface ExtendedUserInfo {
user_id: string;
user_name: string;
@@ -286,6 +289,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
const [settingsOpen, setSettingsOpen] = useState(false);
// isMgmtSite / MANAGEMENT_ONLY_MENU_URLS — DB IS_SOLUTION_ONLY 컬럼으로 이전 (PR #D).
// 백엔드가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 상태 불필요.
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
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) : [];
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
@@ -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;
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;
// componentConfig에서 탭 정보 추출
+37 -11
View File
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11)
// shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화.
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -3660,8 +3661,11 @@ export default function InvyoneStudio({
for (const comp of layout.components) {
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
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 || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -3680,7 +3684,10 @@ export default function InvyoneStudio({
const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) {
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 || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4060,14 +4067,14 @@ export default function InvyoneStudio({
// 컴포넌트별 gridColumns 설정 및 크기 계산
let componentSize = component.defaultSize;
const isTableList = component.id === "table-list";
const isTableLike = isTableLikeComponentType(component.id);
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
let gridColumns = 1; // 기본값
// 특수 컴포넌트
if (isTableList) {
if (isTableLike) {
gridColumns = currentGridColumns; // 테이블은 전체 너비
} else {
// 웹타입별 적절한 그리드 컬럼 수 설정
@@ -4095,7 +4102,11 @@ export default function InvyoneStudio({
"divider-basic": 1, // 구분선 (100%)
"divider-line": 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%)
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
"flow-widget": 1, // 플로우 위젯 (100%)
@@ -4398,8 +4409,11 @@ export default function InvyoneStudio({
for (const comp of layout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
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 || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4418,7 +4432,10 @@ export default function InvyoneStudio({
const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) {
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 || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4618,7 +4635,11 @@ export default function InvyoneStudio({
}
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 tabs = currentConfig.tabs || [];
@@ -5508,11 +5529,16 @@ export default function InvyoneStudio({
}
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 (
targetComponent &&
(compType === "tabs-widget" || compType === "v2-tabs-widget") &&
(compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabsForSelfDrop) &&
dragState.draggedComponent !== containerId
) {
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
@@ -7532,7 +7558,7 @@ export default function InvyoneStudio({
for (const comp of components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
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 || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
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) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
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 || [])) {
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 };
@@ -671,11 +671,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
);
})()}
{/* 탭 컴포넌트 타입 */}
{/* 탭 컴포넌트 타입 (legacy tabs-widget/v2-tabs-widget + canonical container.containerType=tabs) */}
{(type === "tabs" ||
(type === "component" &&
((component as any).componentType === "tabs-widget" ||
(component as any).componentId === "tabs-widget"))) &&
(((component as any).componentType ?? (component as any).component_type) === "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("🎯 탭 컴포넌트 조건 충족:", {
type,
@@ -24,6 +24,7 @@ import {
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
@@ -360,32 +361,44 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
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) {
const compType = (component as any).componentType || component.componentConfig?.type || "";
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
const fillParentTypes = [
"table-list", "v2-table-list",
// ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' /
// 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록.
const fillParentExtraTypes = [
"container",
"grouped-table", "card-list",
"split-panel-layout", "split-panel-layout2",
"v2-split-panel-layout", "screen-split-panel",
"v2-tab-container", "tab-container",
"tabs-widget", "v2-tabs-widget",
];
if (fillParentTypes.some(t => compType === t)) {
if (isTableLikeComponentType(sizingType) || fillParentExtraTypes.includes(sizingType)) {
return "100%";
}
const autoHeightTypes = [
"table-search-widget", "v2-table-search-widget",
"flow-widget",
];
if (autoHeightTypes.some(t => compType === t || compType.includes(t))) {
if (autoHeightTypes.some(t => sizingType === t || sizingType.includes(t))) {
return "auto";
}
}
// 1순위: size.height가 있으면 우선 사용
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장)
if (size?.height && size.height > 0) {
if (component.componentConfig?.type === "table-list") {
if (isTableLikeComponentType(sizingType)) {
return `${Math.max(size.height, 200)}px`;
}
return `${size.height}px`;
@@ -396,8 +409,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
}
// 3순위: 기본값
if (component.componentConfig?.type === "table-list") {
// 3순위: 기본값 (table-like 는 200px 최소 보장)
if (isTableLikeComponentType(sizingType)) {
return "200px";
}
+6 -3
View File
@@ -13,6 +13,7 @@ import {
Link2,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 글로우 펄스 애니메이션 CSS 주입
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) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
// 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등)
if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
return "bg-primary/20 border-primary/40";
}
// 검색 필터
@@ -28,6 +28,17 @@ import { apiClient } from "@/lib/api/client";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
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> = ({
@@ -344,7 +355,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
// 1. 소스 테이블 감지 (현재 화면)
let sourceTableName: string | null = currentTableName || null;
// allComponents에서 분할패널/테이블리스트/통합목록 감지
// allComponents에서 분할패널/테이블리스트(canonical+legacy+v2)/통합목록 감지
for (const comp of allComponents) {
const compType = comp.component_type || (comp as any).component_config?.type;
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;
if (sourceTableName) break;
}
if (compType === "table-list") {
sourceTableName = compConfig.table_name || compConfig.selectedTable || null;
if (isTableLikeComponent(comp)) {
sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
if (sourceTableName) break;
}
if (compType === "v2-list") {
@@ -518,11 +529,11 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
}
}
// 테이블 리스트 타입
if (compType === "table-list") {
sourceTableName = compConfig?.table_name;
// 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두)
if (isTableLikeComponent(comp)) {
sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
if (sourceTableName) {
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
console.log(`✅ [openModalWithData] table-like 컴포넌트에서 소스 테이블 감지: ${sourceTableName}`);
break;
}
}
@@ -2892,9 +2903,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
return isDataTransferComponentType(type);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
@@ -2916,9 +2925,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
return isDataTransferComponentType(type);
}).length === 0 && (
<SelectItem value="__none__" disabled>
@@ -2989,9 +2996,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t),
);
const isReceivable = isDataTransferComponentType(type);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
@@ -3014,9 +3019,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
const isReceivable = isDataTransferComponentType(type);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && (
<SelectItem value="__none__" disabled>
@@ -4430,13 +4433,8 @@ const ExcelUploadConfigSection: React.FC<{
const compId = comp.componentId || comp.componentType;
const compConfig = comp.componentConfig || comp.config || comp;
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기
if (
compId === "table-panel" ||
compId === "data-table" ||
compId === "table-list" ||
compId === "simple-table"
) {
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기 (canonical/legacy/v2 모두)
if (compId === "table-panel" || compId === "simple-table" || isTableLikeComponentType(compId)) {
const tableName = compConfig?.table_name || compConfig?.table;
if (tableName) return tableName;
}
@@ -12,6 +12,7 @@ import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { ComponentData } from "@/types/screen";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
export interface DataTabProps {
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> = ({
config,
onChange,
@@ -106,9 +117,7 @@ export const DataTab: React.FC<DataTabProps> = ({
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
return isDataTransferComponentType(type);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
@@ -130,9 +139,7 @@ export const DataTab: React.FC<DataTabProps> = ({
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t),
);
return isDataTransferComponentType(type);
}).length === 0 && (
<SelectItem value="__none__" disabled>
@@ -198,9 +205,7 @@ export const DataTab: React.FC<DataTabProps> = ({
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t),
);
const isReceivable = isDataTransferComponentType(type);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
@@ -223,9 +228,7 @@ export const DataTab: React.FC<DataTabProps> = ({
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t),
);
const isReceivable = isDataTransferComponentType(type);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && (
<SelectItem value="__none__" disabled>
@@ -147,11 +147,14 @@ interface MultilangSettingsModalProps {
}
// 타입별 아이콘 매핑
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘.
const getTypeIcon = (type: string) => {
switch (type) {
case "button":
return <MousePointer className="h-4 w-4" />;
case "table":
case "table-list":
case "v2-table-list":
return <Table2 className="h-4 w-4" />;
case "split-panel-layout":
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([
"table",
"table-list",
"v2-table-list",
"split-panel-layout",
"tab-panel",
"container",
@@ -205,9 +211,35 @@ const NON_INPUT_COMPONENT_TYPES = new Set([
"modal",
"drawer",
"form-layout",
// canonical stats + 옛 저장 화면 호환 (aggregation-widget / v2-aggregation-widget / v2-status-count)
"stats",
"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 compType = comp.componentType || comp.type;
@@ -727,13 +759,14 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
});
}
// 11. 위젯 (aggregation-widget) 항목 라벨
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
// 11. 카드 (canonical `stats` + legacy aggregation-widget 호환) 항목 라벨
if (isStatsLikeComponentType(compType) && config?.items && Array.isArray(config.items)) {
config.items.forEach((item: any, index: number) => {
if (item.columnLabel && typeof item.columnLabel === "string") {
const itemLabel = getStatsItemLabel(item);
if (itemLabel) {
addLabel(
`${comp.id}_agg_${item.id || index}`,
item.columnLabel,
`${comp.id}_stats_${item.id || index}`,
itemLabel,
"label",
compType,
compLabel,
@@ -42,7 +42,9 @@ export function ComponentsPanel({
// 레지스트리에서 모든 컴포넌트 조회
const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents();
// v2-table-list가 자동 등록되므로 수동 추가 불필요
// ★ 새 생성 경로는 canonical 'table' (displayMode='table').
// v2-table-list 는 옛 저장 화면 호환 hard blocker 로 자동 등록되지만
// 팔레트에는 hidden 처리한다 (아래 hiddenComponents 참고).
return components;
}, []);
@@ -134,8 +136,8 @@ export function ComponentsPanel({
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
"button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout
"aggregation-widget", // → v2-aggregation-widget
"table-list", // → v2-table-list
// aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
"table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
"text-display", // → v2-text-display
"divider-line", // → v2-divider-line
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
@@ -162,9 +164,10 @@ export function ComponentsPanel({
// radio-basic, toggle-switch (Phase F.1)
// image-widget, entity-search-input, autocomplete-search-input, file-upload (일부)
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
"v2-aggregation-widget", // → stats
"v2-status-count", // → stats
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김
// v2-aggregation-widget / v2-status-count: 폴더/Renderer 삭제 (2026-05-19).
// ComponentRegistry 에 없음 — hidden list 에 둘 필요 없음. 옛 저장 화면은
// DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `stats` 라우팅.
// card-display 는 기존 상단에서 이미 숨김
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 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,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
"v2-tabs-widget", // → container (containerType='tabs')
"v2-section-card", // → container (containerType='section', sectionVariant='card')
"v2-section-paper", // → container (containerType='section', sectionVariant='paper')
// v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
// 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요.
// 옛 저장 화면은 DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `container` 라우팅.
"v2-repeat-container", // → container (containerType='repeater')
"v2-repeater", // → container (containerType='repeater')
// accordion-basic, conditional-container, section-card, section-paper,
// tabs, repeat-container, repeat-screen-modal, repeater-field-group,
// screen-split-panel 는 기존 상단에서 이미 숨김
// accordion-basic, conditional-container, repeat-container, repeat-screen-modal,
// repeater-field-group, screen-split-panel 는 기존 상단에서 이미 숨김
// numbering-rule: 폐기 (2026-05-11)
"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
"rack-structure", // → v2-rack-structure
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal
"table-search-widget", // → v2-table-search-widget
"tabs", // → v2-tabs
"tabs-widget", // → v2-tabs-widget
];
return {
@@ -20,7 +20,7 @@ import { LayoutComponent } from "@/types/layout";
// 레거시 ButtonConfigPanel 제거됨
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
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 { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/v2-components";
@@ -871,7 +871,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.component_config?.type || selectedComponent.component_config?.id;
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
if (definition?.config_panel) {
const ConfigPanelComponent = definition.config_panel;
const currentConfig = selectedComponent.component_config || {};
@@ -32,7 +32,7 @@ import DataTableConfigPanel from "./DataTableConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
import {
BaseInputType,
BASE_INPUT_TYPE_OPTIONS,
@@ -266,7 +266,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}
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) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
@@ -767,7 +768,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
const definition = ComponentRegistry.getComponent(componentId);
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
const configPanelFromDef =
(definition as any)?.configPanel ?? (definition as any)?.config_panel;
if (configPanelFromDef) {
@@ -32,8 +32,10 @@ import {
import type { FlowDefinition, FlowStep } from "@/types/flow";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { SingleTableWithSticky } from "@/lib/registry/components/table-list/SingleTableWithSticky";
import type { ColumnConfig } from "@/lib/registry/components/table-list/types";
import {
SingleTableWithSticky,
type ColumnConfig,
} from "@/lib/registry/components/table/_shared/SingleTableWithSticky";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
@@ -10,6 +10,7 @@ import { useActiveTab } from "@/contexts/ActiveTabContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen";
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
// 확장된 TabItem 타입 (screen_id 지원)
interface ExtendedTabItem extends TabItem {
@@ -142,11 +143,11 @@ export function TabsWidget({
for (const tab of tabs as ExtendedTabItem[]) {
const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
const tableComp = inlineComponents.find(
(c) => c.component_type === "v2-table-list" || c.component_type === "table-list",
);
const selectedTable = tableComp?.component_config?.selectedTable;
// 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
// (canonical table / legacy table-list / hidden v2-table-list 모두 인식,
// camelCase / snake_case 양쪽 모두 처리)
const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
const selectedTable = getTableNameFromTableLikeComponent(tableComp);
if (selectedTable || tab.screen_id) {
map[tab.id] = {
id: tab.screen_id,
@@ -229,7 +229,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// ============================================================
// repeaterDataChange 이벤트 발행
// 데이터 변경 시 다른 컴포넌트(aggregation-widget 등)에 알림
// 데이터 변경 시 다른 컴포넌트(canonical stats 등)에 알림
// ============================================================
const prevDataLengthRef = useRef(data.length);
useEffect(() => {
+1 -1
View File
@@ -8,7 +8,7 @@
*/
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";
/**
@@ -71,6 +71,7 @@ import {
import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel";
import type { ComponentData } from "@/types/screen";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// ───────────────────────────────────────────────────────
// 상수: 액션 / 표시 / 변형
@@ -112,6 +113,16 @@ const MODAL_SIZE_OPTIONS = [
{ value: "full", label: "전체" },
] 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 = [
{ value: "append", label: "추가" },
{ value: "replace", label: "교체" },
@@ -810,9 +821,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
{p.allComponents
.filter((c: any) => {
const t = c.componentType || c.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(x) => t.includes(x),
);
return isDataTransferComponentType(t);
})
.map((c: any) => (
<option key={c.id} value={c.id}>
@@ -841,9 +850,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
{p.allComponents
.filter((c: any) => {
const t = c.componentType || c.type || "";
const ok = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(x) => t.includes(x),
);
const ok = isDataTransferComponentType(t);
return ok && c.id !== dt.sourceComponentId;
})
.map((c: any) => (
File diff suppressed because it is too large Load Diff
@@ -22,8 +22,8 @@ import {
} from "@/components/ui/collapsible";
import { Table2, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types";
import { TableListConfigPanel } from "@/lib/registry/components/table/_shared/TableListConfigPanel";
import type { TableListConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
interface V2ListConfigPanelProps {
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 { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
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";
// ─── DnD 정렬 가능한 컬럼 행 (접이식) ───
+2 -22
View File
@@ -52,30 +52,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
try {
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만 표시
// 로케일은 useAuth.fetchCurrentUser 가 /auth/me 응답에서 세팅 완료 후 user.company_code 가 채워지므로
// 이 함수가 호출되는 시점에는 항상 __GLOBAL_USER_LANG 이 세팅되어 있음 → 별도 대기 불필요
const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
if (adminResponse.success && adminResponse.data) {

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