Compare commits

...

151 Commits

Author SHA1 Message Date
hjlee 147d187901 Merge pull request 'lhj' (#372) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/372
2026-01-19 17:26:05 +09:00
leeheejin d09a6977f7 검색필터 업그레이드 2026-01-19 17:25:12 +09:00
leeheejin faf4100056 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-19 13:17:10 +09:00
kjs 410b4a7b14 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-19 12:07:30 +09:00
kjs e4667cce5f refactor: 테이블 관리 서비스에서 쿼리 및 로깅 개선
- 다중 값 배열 검색 시 조건 처리 로직 개선
- 쿼리에서 main. 접두사 추가하여 명확한 테이블 참조 보장
- 불필요한 공백 제거 및 코드 가독성 향상
- 엔티티 관계 감지 로깅 개선으로 디버깅 용이성 증가
- 새로운 수주관리 및 거래처 테이블 추가로 멀티테넌시 지원 강화
2026-01-19 12:07:29 +09:00
leeheejin c282d5c611 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-19 10:14:48 +09:00
leeheejin d4afc06f4a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-19 10:14:38 +09:00
leeheejin f2ab4f11bd 진짜 해결했음 진짜진짜로 2026-01-19 10:14:20 +09:00
hjlee 514d852fa6 Merge pull request '배포 다시..' (#371) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/371
2026-01-19 09:50:47 +09:00
leeheejin 8603fddbcb 배포 다시.. 2026-01-19 09:50:25 +09:00
hjlee 58adc0a100 Merge pull request 'lhj' (#370) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/370
2026-01-16 18:15:42 +09:00
leeheejin 0382c94d73 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 18:15:21 +09:00
leeheejin 49f67451eb 피벗수정오늘최종 2026-01-16 18:14:55 +09:00
DDD1542 e3852aca5d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-16 17:41:21 +09:00
DDD1542 df8065503d feat: 메뉴 삭제 및 동기화 기능 개선
- 메뉴 삭제 시 하위 메뉴를 재귀적으로 수집하여 관련 데이터 정리 기능 추가
- 메뉴 삭제 성공 시 삭제된 메뉴와 하위 메뉴 수를 포함한 응답 메시지 개선
- 메뉴 복제 시 항상 활성화 상태로 설정
- 화면-메뉴 동기화 진행 상태를 사용자에게 알리기 위한 프로그레스 메시지 추가
- 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵하는 로직 추가
- 동기화 진행 중 오버레이 UI 개선
2026-01-16 17:41:19 +09:00
hjlee 0a85146564 Merge pull request 'lhj' (#369) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/369
2026-01-16 17:40:11 +09:00
leeheejin ad3b853d04 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 17:39:55 +09:00
leeheejin 2a3cc7ba00 배포다시 되게 고쳐놓음 2026-01-16 17:39:35 +09:00
hjlee ee273c5103 Merge pull request 'lhj' (#368) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/368
2026-01-16 16:51:03 +09:00
leeheejin 50a25cb9de Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 16:50:34 +09:00
leeheejin d1631d15ff 안닫히게 수정 2 2026-01-16 16:49:59 +09:00
hjlee a020985630 Merge pull request 'lhj' (#367) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/367
2026-01-16 16:25:12 +09:00
leeheejin 351ecbb35d 배포에서 오류 안나게 수정 2026-01-16 16:24:43 +09:00
leeheejin d32e933c03 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 16:12:09 +09:00
leeheejin 4497985104 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-16 16:11:34 +09:00
kjs b97b0cc7d7 refactor: 화면 그룹 관련 API에서 AuthenticatedRequest 타입 사용
- 화면 그룹 목록 조회, 상세 조회, 생성, 수정, 삭제 API에서 Request 타입을 AuthenticatedRequest로 변경하여 사용자 인증 정보를 명확히 처리
- companyCode를 req.user?.companyCode || "*"로 설정하여 기본값 처리 개선
- 관련 API의 일관성 있는 타입 사용으로 코드 가독성 및 유지보수성 향상
2026-01-16 15:52:35 +09:00
hjlee 160ad87395 Merge pull request 'lhj' (#364) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/364
2026-01-16 15:18:32 +09:00
leeheejin 4972f26cee Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 15:18:09 +09:00
leeheejin 02eee979ea 고치기 완료 2026-01-16 15:17:49 +09:00
DDD1542 08de1372c5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-16 14:48:21 +09:00
DDD1542 ab52c49492 feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
- 화면 관리 시스템의 복제, 삭제, 수정 및 테이블 설정 기능을 전면 개선
- 그룹 삭제 시 하위 그룹과의 연관성 정리 및 로딩 프로그레스 바 추가
- 화면 수정 기능 추가: 이름, 그룹, 역할, 정렬 순서 변경
- 테이블 설정 모달에 관련 기능 추가 및 데이터 일관성 유지
- 메뉴-화면 그룹 동기화 API 추가 및 관련 상태 관리 기능 구현
- 검색어 필터링 로직 개선: 다중 키워드 지원
- 관련 파일 및 진행 상태 업데이트
2026-01-16 14:48:15 +09:00
leeheejin 8a865ac1f4 이상한 부분 수정 피벗그리드 2026-01-16 14:29:19 +09:00
hjlee 0a89cc2fb0 Merge pull request '피벗' (#363) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/363
2026-01-16 14:03:31 +09:00
leeheejin ab3a493abb 피벗 2026-01-16 14:03:07 +09:00
hjlee ac0f461832 Merge pull request 'lhj' (#362) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/362
2026-01-16 10:19:35 +09:00
leeheejin c2256de8ec Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 10:19:05 +09:00
leeheejin 484c98da9e 피벗그리드 고침 2026-01-16 10:18:11 +09:00
DDD1542 b2dc06d0f2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-15 17:36:41 +09:00
DDD1542 efa95af4b9 refactor: 코드 정리 및 불필요한 주석 제거
- EntityJoinController에서 중복 제거 설정 관련 주석 및 코드 삭제
- screenGroupController와 tableManagementController에서 AuthenticatedRequest 타입을 일반 Request로 변경
- 불필요한 로그 및 주석 제거로 코드 가독성 향상
- tableManagementController에서 에러 메시지 개선
2026-01-15 17:36:38 +09:00
SeongHyun Kim e8bdcbb95c fix: 발주/입고관리 그룹 편집 시 단건만 저장되던 문제 수정
EditModal.tsx: conditional-container 존재 시 onSave 미전달 로직 수정
ModalRepeaterTableComponent.tsx: groupedData prop 우선 사용하도록 변경
2026-01-15 17:05:01 +09:00
SeongHyun Kim 60ae073606 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-15 16:56:44 +09:00
SeongHyun Kim a36802ab10 fix: RepeaterFieldGroup 저장 경로에 채번 규칙 할당 로직 추가 2026-01-15 16:54:02 +09:00
hjlee 98c489ee22 Merge pull request '코드로 보이던 문제 최소한의 코드만 고침' (#360) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/360
2026-01-15 16:53:40 +09:00
leeheejin c77c6290d3 코드로 보이던 문제 최소한의 코드만 고침 2026-01-15 16:53:27 +09:00
gbpark 9dc549be09 Merge pull request 'feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)' (#359) from feature/v2-renewal into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/359
2026-01-15 14:59:21 +09:00
gbpark 40a226ca30 Merge branch 'main' into feature/v2-renewal 2026-01-15 14:59:12 +09:00
DDD1542 5d89b69451 feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)
- 단일 화면 복제 및 그룹 전체 복제 기능 추가
- 정렬 순서 유지 및 일괄 이름 변경 기능 구현
- 삭제 기능 개선: 단일 화면 삭제 및 그룹 삭제 시 옵션 추가
- 회사 코드 지원 기능 추가: 복제된 그룹/화면에 선택한 회사 코드 적용
- 관련 파일 및 진행 상태 업데이트
2026-01-15 14:58:12 +09:00
SeongHyun Kim 7fd3364aef Merge branch 'ksh' 2026-01-15 13:27:01 +09:00
hjlee 2326c3548b Merge pull request 'lhj' (#358) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/358
2026-01-15 12:08:46 +09:00
leeheejin 220ce57be1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-15 12:07:51 +09:00
leeheejin 0ac83b1551 분할패널에서 수정해도 리스트에서 삭제가 안되던 문제 코드 해결 2026-01-15 12:07:26 +09:00
kjs 3f474ecddd 오류수정 2026-01-15 11:00:30 +09:00
kjs ddf5ed4006 빌드에러 2026-01-15 10:52:54 +09:00
kjs c4ee084a1d 빌드에러 수정 2026-01-15 10:47:58 +09:00
SeongHyun Kim 2e02ace388 feat(repeater): 하위 데이터 조회 및 조건부 입력 기능 구현, 테이블 선택 데이터 동기화 개선
Repeater 컴포넌트에 하위 데이터 조회 기능 추가 (재고/단가 조회)
조건부 입력 활성화 및 최대값 제한 기능 구현
필드 정의 순서 변경 기능 추가 (드래그앤드롭, 화살표 버튼)
TableListComponent의 DataProvider 클로저 문제 해결
ButtonPrimaryComponent에 modalDataStore fallback 로직 추가
2026-01-15 10:35:34 +09:00
kjs 435eb90763 Merge pull request 'feat/multilang' (#357) from feat/multilang into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/357
2026-01-14 18:28:20 +09:00
kjs 98870b3348 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 17:14:42 +09:00
kjs b7b750d134 번역현황 표시 2026-01-14 17:14:27 +09:00
kjs ac334db0b1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 16:33:38 +09:00
kjs 16c9c71a23 다국어 화면에서 수정기능 구현 2026-01-14 16:33:22 +09:00
DDD1542 059ea6b30a feat: 테이블 설정 모달의 컬럼 저장 로직 개선
- 변경된 컬럼들만 저장하도록 로직을 개선하여 불필요한 API 호출을 줄임
- 기존 컬럼 정보와 편집된 값을 병합하여 최종 설정을 구성
- 엔티티 및 코드 타입에 대한 세부 설정 처리 로직 추가
- 저장 성공 시 사용자에게 피드백 제공 및 편집 상태 초기화 기능 구현
2026-01-14 16:09:00 +09:00
kjs 14f8714ea1 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 15:39:04 +09:00
kjs a27cb85007 코드 정리 및 최적화: InteractiveScreenViewer 컴포넌트의 불필요한 공백 제거 및 Select 컴포넌트의 props 정리 2026-01-14 15:38:52 +09:00
kjs b5d2195cd5 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 15:38:41 +09:00
DDD1542 0a3d42f3ad feat: 테이블 타입 관리 모달 추가 및 기존 테이블 생성 모달 제거
- 테이블 설정 모달에 테이블 타입 관리 모달을 추가하여 데이터베이스 테이블과 컬럼의 타입을 관리할 수 있도록 개선
- 기존의 테이블 생성 모달을 제거하고, 버튼 텍스트를 "새 테이블 생성"에서 "테이블 타입 관리"로 변경
- 테이블 타입 관리 모달의 헤더 및 설명 추가, 모달 닫기 버튼 기능 구현
- 테이블 데이터 새로고침 기능을 추가하여 모달 닫기 시 데이터가 최신 상태로 유지되도록 개선
2026-01-14 15:37:29 +09:00
kjs b5c2e85496 화면 다국어 처리 2026-01-14 15:33:57 +09:00
DDD1542 f321aaf7aa feat: 화면 디자이너 모달 및 제어 관리 탭 기능 추가
- 화면 설정 모달에 "제어 관리" 탭 추가하여 버튼 제어 설정을 간편하게 관리
- 버튼 액션 설정 기능 구현: 버튼 목록 표시 및 각 버튼의 액션 타입 수정 가능
- 화면 디자이너 모달 통합: 전체화면 Dialog 내부에 ScreenDesigner 임베드
- URL 쿼리 파라미터로 화면 디자이너 자동 열기 기능 추가
- 화면 캔버스 크기 자동 조절 기능 구현: 최소 크기 보장 및 여유 마진 추가
- 필드 추가/제거 기능 개선: 기존 그리드 컬럼 변경 로직과 통합하여 사용자 경험 향상
2026-01-14 14:35:27 +09:00
kjs 26bb93ab6e 다국어 생성후 매핑 자동저장 2026-01-14 13:26:41 +09:00
kjs f9575d7b5f 다국어 버튼 자동매핑 2026-01-14 13:08:44 +09:00
kjs c26b346054 다국어설정 모달생성 2026-01-14 11:51:24 +09:00
kjs 24315215de 다국어 키 자동생성 로직 2026-01-14 11:05:57 +09:00
SeongHyun Kim ca73685bc2 Merge remote-tracking branch 'origin/main' into ksh 2026-01-14 10:22:58 +09:00
kjs 61a7f585b4 다국어 자동생성 2026-01-14 10:20:27 +09:00
SeongHyun Kim cf97db7fbf feat(universal-form-modal): Select 필드 직접 입력(Combobox) 모드 추가
SelectOptionConfig에 allowCustomInput 옵션 추가
FieldDetailSettingsModal에 "직접 입력 허용" Switch UI 추가
CascadingSelectField에 Combobox 모드 구현 (Popover+Command)
SelectField에 Combobox 모드 구현
목록 선택과 직접 입력 동시 지원
2026-01-13 18:44:59 +09:00
kjs 18b5161398 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-13 18:32:04 +09:00
kjs b576837f18 다국어 관리 시스템 개선: 카테고리 및 키 자동 생성 기능 추가 2026-01-13 18:28:11 +09:00
SeongHyun Kim ef27e0e38f feat(universal-form-modal): 연쇄 드롭다운(Cascading Dropdown) 기능 구현
- SelectOptionConfig에 cascading 타입 및 설정 객체 추가
- FieldDetailSettingsModal에 연쇄 드롭다운 설정 UI 구현
  - 부모 필드 선택 (섹션별 그룹핑 콤보박스)
  - 관계 코드 선택 시 상세 설정 자동 채움
  - 소스 테이블, 부모 키 컬럼, 값/라벨 컬럼 설정
- UniversalFormModalComponent에 자식 필드 초기화 로직 추가
- selectOptions.cascading 방식 CascadingSelectField 렌더링 지원
2026-01-13 18:26:41 +09:00
SeongHyun Kim d7d7dabe84 fix: Select 옵션의 참조 테이블 컬럼 자동 로드 추가
- FieldDetailSettingsModal에서 모달 열릴 때 Select 옵션의 참조 테이블 컬럼을 자동으로 로드하도록 useEffect 추가
- field.selectOptions.tableName이 설정되어 있고 해당 테이블의 컬럼이 로드되지 않은 경우 onLoadTableColumns 호출
- 기존 linkedFieldGroup의 sourceTable 로드 로직과 동일한 패턴 적용
2026-01-13 17:05:16 +09:00
hjlee d22fd078be Merge pull request 'lhj' (#356) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/356
2026-01-13 15:43:38 +09:00
leeheejin 28fe908704 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 15:43:24 +09:00
leeheejin 1b5ae5fe1c 일단 오류 수정 2026-01-13 15:43:04 +09:00
DDD1542 905a9f62c3 feat: 프리뷰 모드에서 회사 코드 오버라이드 기능 추가
- 최고 관리자만 다른 회사 코드로 오버라이드 가능하도록 로직 개선
- entityJoinController 및 tableManagementController에서 회사 코드 오버라이드 처리 추가
- 관련 API 호출 시 오버라이드된 회사 코드 적용
- 프리뷰 모드 감지 및 UI 개선을 위한 코드 추가
2026-01-13 13:28:50 +09:00
kjs 989b7e53a7 Merge pull request 'feature/screen-management' (#355) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/355
2026-01-13 10:25:20 +09:00
kjs 20e144af36 Merge branch 'main' into feature/screen-management 2026-01-13 10:25:08 +09:00
hjlee e2a22bb853 Merge pull request 'lhj' (#354) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/354
2026-01-13 10:04:41 +09:00
leeheejin 0deb466557 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 10:04:19 +09:00
leeheejin f64279d084 카테고리 연속입력 2026-01-13 10:04:05 +09:00
SeongHyun Kim c74e97d66e chore: update react-is to version 18.3.1 and downgrade recharts to version 3.2.1 in package.json and package-lock.json 2026-01-13 09:35:01 +09:00
kjs 0beb8b20a3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-13 09:30:59 +09:00
hjlee 054da65a26 Merge pull request 'lhj' (#353) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/353
2026-01-13 09:30:27 +09:00
kjs 75e6c9eb1a 엑셀업로드 제어로직 개선 2026-01-13 09:30:19 +09:00
leeheejin 0f2d0bb053 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 09:30:11 +09:00
leeheejin 306de370f1 탭 값 필터링 2026-01-13 09:29:58 +09:00
kjs b6fefe2ebd 엑셀 업로드 로직 개선 2026-01-12 17:43:34 +09:00
SeongHyun Kim f799402564 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ksh 2026-01-12 17:28:11 +09:00
leeheejin 033f5eaf7e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 17:27:28 +09:00
leeheejin d094b58ebf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-12 17:27:18 +09:00
hjlee 3fa57ad2ae Merge pull request 'lhj' (#352) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/352
2026-01-12 17:26:48 +09:00
SeongHyun Kim 821955cfac fix: 렉 구조 등록 카테고리 데이터 저장 방식 통일 및 중복 체크 수정
RackStructureComponent: context에 카테고리 코드 필드(floorCode, zoneCode 등) 추가
기존 데이터 조회 시 warehouse_id -> warehouse_code 컬럼명 수정
미리보기 생성 시 카테고리 코드로 저장하도록 변경
미리보기 테이블에서 카테고리 코드를 라벨로 변환하여 표시
buttonActions: 중복 체크 API 호출 시 filters -> search 파라미터로 변경
types: RackStructureContext 인터페이스에 카테고리 코드 필드 추가
2026-01-12 17:25:52 +09:00
leeheejin b358a46c33 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 17:25:29 +09:00
hjjeong b2add92abf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-12 17:25:22 +09:00
leeheejin c2836a0209 일단 퇴사일 입력하면 필터링 적용되게 수정해놓음 2026-01-12 17:25:12 +09:00
hjjeong 472fc8633c 상단마스터정보+하단품목정보 수정시 오류 수정 2026-01-12 17:24:25 +09:00
kjs 4801ee5ca4 Merge pull request 'feature/screen-management' (#351) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/351
2026-01-12 17:20:23 +09:00
kjs 87189c792e 카테고리값 자동감지 2026-01-12 16:08:02 +09:00
kjs 9cc5bbbf05 엑셀 업로드 문제 수정 2026-01-12 13:53:57 +09:00
kjs 5f991db9c4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-12 10:33:01 +09:00
kjs 9e7253a293 카테고리 라벨 보이지 않는 문제 수정 2026-01-12 10:32:41 +09:00
leeheejin 31e87e0bca Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 09:25:13 +09:00
DDD1542 0773989c74 feat: 데이터 흐름 조회 기능 개선 및 프리뷰 모드 추가
- 데이터 흐름 조회 API에 source_screen_id 파라미터 추가하여 특정 화면에서 시작하는 데이터 흐름만 조회 가능
- 화면 관리 페이지에서 선택된 그룹에 company_code 필드 추가하여 회사 코드 정보 포함
- 프리뷰 모드에서 URL 쿼리로 company_code를 받아와 데이터 조회 시 우선 사용하도록 로직 개선
- 화면 관계 흐름 및 서브 테이블 정보에서 company_code를 활용하여 필터링 및 시각화 개선
2026-01-09 18:26:37 +09:00
kjs 6732e7d969 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 18:22:51 +09:00
kjs 35f83c1937 로그 정리 2026-01-09 18:22:50 +09:00
kjs 8aa6008351 Merge pull request 'feature/screen-management' (#350) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/350
2026-01-09 17:57:08 +09:00
kjs 47b61a9a35 Merge branch 'main' into feature/screen-management 2026-01-09 17:57:01 +09:00
kjs d22c2ec96e Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 17:56:49 +09:00
kjs 3677c77da0 단일테이블 엑셀 업로드 채번 구현 2026-01-09 17:56:48 +09:00
SeongHyun Kim c11e80a43c Merge remote-tracking branch 'origin/main' into ksh 2026-01-09 17:46:44 +09:00
SeongHyun Kim f8fb7d687e fix: SelectedItemsDetailInput 수정 모드에서 다중 레코드 로드 오류 수정
DynamicComponentRenderer에 selected-items-detail-input groupedData 전달 추가
SelectedItemsDetailInput에서 groupedData 우선 사용하도록 변경
ScreenModal editData 배열 처리 시 formData/selectedData 분리 저장
TextInput 수정 모드에서 채번 규칙 스킵 로직 추가
2026-01-09 17:42:33 +09:00
DDD1542 a6569909a2 feat: 저장 테이블 제외 조건 추가 및 포커싱 개선
- 저장 테이블 쿼리에 table-list와 체크박스가 활성화된 화면, openModalWithData 버튼이 있는 화면을 제외하는 조건 추가
- 화면 그룹 클릭 시 새 그룹 진입 시 포커싱 없이 시작하도록 로직 개선
- 관련 문서에 제외 조건 및 SQL 예시 추가
2026-01-09 17:03:00 +09:00
leeheejin 5c9dda6826 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:58:26 +09:00
leeheejin bcf512d2b5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-09 15:58:16 +09:00
kjs 4d41cb40b6 Merge pull request '빌드에러 수정' (#349) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/349
2026-01-09 15:57:15 +09:00
kjs bf74dd0f92 Merge branch 'main' into feature/screen-management 2026-01-09 15:57:09 +09:00
kjs 85ae1c1521 빌드에러 수정 2026-01-09 15:56:56 +09:00
leeheejin 38455325dd Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:48:39 +09:00
kjs f493f8ac80 Merge pull request 'feature/screen-management' (#348) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/348
2026-01-09 15:46:41 +09:00
kjs 7fc341bca8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 15:46:24 +09:00
kjs ba2a281245 엑셀 업로드 제어로직 설정 가능하도록 수정 2026-01-09 15:46:09 +09:00
kjs aa0698556e 엑셀 업로드,다운로드 기능 개선 2026-01-09 15:32:02 +09:00
leeheejin c76123a927 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:21:12 +09:00
leeheejin ba20a2bf42 피벗 테스트만 하면 됨 기능은 완료 2026-01-09 15:11:30 +09:00
SeongHyun Kim 23c9604672 Merge origin/main into ksh - resolve conflicts 2026-01-09 14:55:16 +09:00
SeongHyun Kim 64c6942de3 feat(split-panel-layout): 멀티 테이블 탭 기능 추가
AdditionalTabConfig 타입 정의 (우측 패널과 동일한 설정 구조)
설정 패널에 추가 탭 설정 UI 구현 (테이블, 조인키, 컬럼, 필터, 중복제거, 버튼)
컴포넌트에 탭 상태 관리 및 데이터 로딩 로직 추가
탭 바 UI 및 탭별 컨텐츠 렌더링 구현
기존 기능 유지 (탭 미사용 시 동일 동작)
2026-01-09 14:52:32 +09:00
leeheejin f07448ac17 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 14:41:57 +09:00
leeheejin d49883d25f 피벗 추가 2026-01-09 14:41:27 +09:00
hjjeong 217e390fe9 Merge pull request 'fix/split-panel-edit-group-records' (#347) from fix/split-panel-edit-group-records into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/347
2026-01-09 14:34:16 +09:00
kjs ee3a648917 삭제버튼 제어 동작하지 않던 오류 수정 2026-01-09 13:43:14 +09:00
leeheejin 819a281df4 fix: 피벗그리드 컴포넌트 레지스트리 등록 누락 수정 2026-01-09 13:34:55 +09:00
leeheejin dd1d3bb44d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 13:28:40 +09:00
leeheejin 52e6824e76 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-09 13:28:26 +09:00
kjs 80cf20e142 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 12:17:27 +09:00
kjs 150a40e2a8 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 11:21:17 +09:00
kjs cea3aa53ae 엑셀 업로드 분할테이블 지원 2026-01-09 11:21:16 +09:00
DDD1542 af4072cef1 feat: 저장 테이블 정보 및 애니메이션 기능 추가
- 화면 서브 테이블에서 저장 테이블 정보를 추출하는 쿼리 추가
- 저장 테이블 정보 구조를 TableNodeData 인터페이스에 통합
- 저장 테이블의 시각적 표현을 위한 애니메이션 효과 추가
- 필터링 및 참조 관계 뱃지 레이아웃 개선
- 테이블 높이 부드러운 애니메이션 및 스크롤 기능 구현
2026-01-09 11:19:30 +09:00
DDD1542 b8c8b31033 feat: 서브 테이블 조인 및 필터 관계 정보 개선
- rightPanel.columns에서 참조하는 외부 테이블 및 조인 컬럼 정보를 수집하는 로직 추가
- 서브 테이블의 조인 컬럼 참조 정보를 포함하여 시각화 개선
- 필터 관계를 선 없이 뱃지로 표시하여 겹침 방지 및 시각적 표현 개선
- TableNodeData 인터페이스에 조인 컬럼 및 참조 정보 필드 추가
- 화면 관계 흐름에서 조인 컬럼 및 필터 관계 정보 표시 기능 개선
2026-01-08 16:20:51 +09:00
SeongHyun Kim 34e48993e4 feat(SplitPanelLayout2): 개별 수정 버튼에 모달 화면 선택 UI 추가
- 좌측/우측 패널의 개별 수정 버튼 설정에 수정 모달 화면 선택 Combobox 추가
- 수정 버튼 ON 시 모달 화면 선택 UI 표시
- editModalScreenId 설정값 저장 및 사용
- 기존 폴백 로직 유지 (editModalScreenId 없으면 addModalScreenId 사용)
2026-01-08 15:37:22 +09:00
DDD1542 8928d851ca feat: 서브 테이블 정보 및 관계 시각화 개선
- 화면 서브 테이블에서 valueField, parentFieldId, cascadingParentField, controlField 정보를 추출하는 쿼리 추가
- 서브 테이블의 관계 유형을 추론하기 위한 추가 정보 필드(originalRelationType, foreignKey, leftColumn) 포함
- 필터링에 사용되는 FK 컬럼을 TableNodeData 인터페이스에 추가하여 시각화 개선
- 관계 유형별 색상 정의 및 시각적 관계 유형 추론 함수 추가
- 화면 관계 흐름에서 서브 테이블 연결선 및 필터링 참조 정보 표시 기능 개선
2026-01-08 14:24:33 +09:00
DDD1542 b279f8d58d feat: 화면 관리 기능 개선 및 서브 테이블 정보 추가
- 화면 선택 시 그룹을 재설정하지 않도록 로직 개선하여 데이터 재로드 방지
- 테이블 노드 데이터 구조에 필드 매핑 정보 추가
- 서브 테이블과 조인 관계를 시각화하기 위한 컬럼 강조 및 스타일링 개선
- 화면 관계 흐름에서 서브 테이블 연결선 강조 기능 추가
- 사용 컬럼 및 조인 컬럼 정보를 화면별로 매핑하여 관리
2026-01-07 14:50:03 +09:00
DDD1542 48e9840fa0 feat: 화면 그룹 생성 및 업데이트 기능 개선
- 화면 그룹 생성 시 회사 코드 결정 로직 추가: 최고 관리자가 특정 회사를 선택할 수 있도록 변경
- 부모 그룹이 있는 경우 그룹 레벨 및 계층 경로 계산 로직 추가
- 화면 그룹 생성 후 계층 경로 업데이트 기능 구현
- 화면 그룹 업데이트 시 불필요한 코드 제거 및 최적화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 개선
2026-01-07 14:49:49 +09:00
DDD1542 6925e3af3f feat: 화면 서브 테이블 정보 조회 기능 추가
- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현
- 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가
- 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
2026-01-05 18:18:26 +09:00
DDD1542 7caf2dea94 feat: 화면 그룹 관리 기능 추가
- 화면 그룹 CRUD API 및 라우트 구현
- 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가
- 화면-그룹 연결 및 데이터 흐름 관리 기능 포함
- 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
2026-01-05 10:05:31 +09:00
131 changed files with 41738 additions and 4245 deletions

View File

@ -0,0 +1,559 @@
# 다국어 지원 컴포넌트 개발 가이드
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
---
## 1. 타입 정의 시 다국어 필드 추가
### 기본 원칙
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
### 단일 텍스트 속성
```typescript
interface MyComponentConfig {
// 기본 텍스트
title?: string;
// 다국어 키 (필수 추가)
titleLangKeyId?: number;
titleLangKey?: string;
// 라벨
label?: string;
labelLangKeyId?: number;
labelLangKey?: string;
// 플레이스홀더
placeholder?: string;
placeholderLangKeyId?: number;
placeholderLangKey?: string;
}
```
### 배열/목록 속성 (컬럼, 탭 등)
```typescript
interface ColumnConfig {
name: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 기타 속성
width?: number;
align?: "left" | "center" | "right";
}
interface TabConfig {
id: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 탭 제목도 별도로
title?: string;
titleLangKeyId?: number;
titleLangKey?: string;
}
interface MyComponentConfig {
columns?: ColumnConfig[];
tabs?: TabConfig[];
}
```
### 버튼 컴포넌트
```typescript
interface ButtonComponentConfig {
text?: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
}
```
### 실제 예시: 분할 패널
```typescript
interface SplitPanelLayoutConfig {
leftPanel?: {
title?: string;
langKeyId?: number; // 좌측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number; // 각 컬럼 다국어
langKey?: string;
}>;
};
rightPanel?: {
title?: string;
langKeyId?: number; // 우측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
additionalTabs?: Array<{
label: string;
langKeyId?: number; // 탭 라벨 다국어
langKey?: string;
title?: string;
titleLangKeyId?: number; // 탭 제목 다국어
titleLangKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
}>;
};
}
```
---
## 2. 라벨 추출 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractMultilangLabels` 함수에 추가
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
```typescript
// 새 컴포넌트 타입 체크
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 추출
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,-
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 2. 컬럼 추출
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col, index) => {
const colLabel = col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: colLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
if (config?.text) {
addLabel({
id: `${comp.id}_button`,
componentId: `${comp.id}_button`,
label: config.text,
type: "button",
parentType: "my-new-component",
parentLabel: config.text,
langKeyId: config.langKeyId,
langKey: config.langKey,
});
}
}
```
### 추출해야 할 라벨 타입
| 타입 | 설명 | 예시 |
| ------------- | ------------------ | ------------------------ |
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
---
## 3. 매핑 적용 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `applyMultilangMappings` 함수에 추가
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
```typescript
// 새 컴포넌트 매핑 적용
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 매핑
const titleMapping = mappingMap.get(`${comp.id}_title`);
if (titleMapping) {
updated.componentConfig = {
...updated.componentConfig,
titleLangKeyId: titleMapping.keyId,
titleLangKey: titleMapping.langKey,
};
}
// 2. 컬럼 매핑
if (config?.columns && Array.isArray(config.columns)) {
const updatedColumns = config.columns.map((col, index) => {
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
if (colMapping) {
return {
...col,
langKeyId: colMapping.keyId,
langKey: colMapping.langKey,
};
}
return col;
});
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
};
}
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
const buttonMapping = mappingMap.get(`${comp.id}_button`);
if (buttonMapping) {
updated.componentConfig = {
...updated.componentConfig,
langKeyId: buttonMapping.keyId,
langKey: buttonMapping.langKey,
};
}
}
```
### 주의사항
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
```typescript
// 잘못된 방법 - 이전 업데이트 덮어쓰기
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
// 올바른 방법 - 이전 업데이트 유지
updated.componentConfig = {
...updated.componentConfig,
langKeyId: mapping.keyId,
}; // ✅
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
}; // ✅
```
---
## 4. 번역 표시 로직 구현
### 파일 위치
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
### Context 사용
```typescript
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
const MyComponent = ({ component }: Props) => {
const { getTranslatedText } = useScreenMultiLang();
const config = component.componentConfig;
// 제목 번역
const displayTitle = config?.titleLangKey
? getTranslatedText(config.titleLangKey, config.title || "")
: config?.title || "";
// 컬럼 헤더 번역
const translatedColumns = config?.columns?.map((col) => ({
...col,
displayLabel: col.langKey
? getTranslatedText(col.langKey, col.label)
: col.label,
}));
// 버튼 텍스트 번역
const buttonText = config?.langKey
? getTranslatedText(config.langKey, config.text || "")
: config?.text || "";
return (
<div>
<h2>{displayTitle}</h2>
<table>
<thead>
<tr>
{translatedColumns?.map((col, idx) => (
<th key={idx}>{col.displayLabel}</th>
))}
</tr>
</thead>
</table>
<button>{buttonText}</button>
</div>
);
};
```
### getTranslatedText 함수
```typescript
// 첫 번째 인자: langKey (다국어 키)
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
const text = getTranslatedText(
"screen.company_1.Sales.OrderList.품목명",
"품목명"
);
```
### 주의사항
- `langKey`가 없으면 원본 텍스트를 표시합니다.
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
---
## 5. ScreenMultiLangContext에 키 수집 로직 추가
### 파일 위치
`frontend/contexts/ScreenMultiLangContext.tsx`
### `collectLangKeys` 함수에 추가
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
```typescript
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
const keys = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 langKey 수집
if (comp.componentType === "my-new-component") {
// 제목
if (config?.titleLangKey) {
keys.add(config.titleLangKey);
}
// 컬럼
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col: any) => {
if (col.langKey) {
keys.add(col.langKey);
}
});
}
// 버튼
if (config?.langKey) {
keys.add(config.langKey);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return keys;
};
```
---
## 6. MultilangSettingsModal에 표시 로직 추가
### 파일 위치
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
### `extractLabelsFromComponents` 함수에 추가
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
```typescript
// 새 컴포넌트 라벨 추출
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 제목
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 컬럼
if (config?.columns) {
config.columns.forEach((col, index) => {
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
const tableName = config.tableName;
const displayLabel =
tableName && columnLabelMap[tableName]?.[col.name]
? columnLabelMap[tableName][col.name]
: col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: displayLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
}
```
---
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractTableNames` 함수에 추가
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
```typescript
const extractTableNames = (comps: ComponentData[]): Set<string> => {
const tableNames = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 테이블명 추출
if (comp.componentType === "my-new-component") {
if (config?.tableName) {
tableNames.add(config.tableName);
}
if (config?.selectedTable) {
tableNames.add(config.selectedTable);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return tableNames;
};
```
---
## 8. 체크리스트
새 컴포넌트 개발 시 다음 항목을 확인하세요:
### 타입 정의
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
### 라벨 추출 (multilangLabelExtractor.ts)
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
### 매핑 적용 (multilangLabelExtractor.ts)
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
### 번역 표시 (컴포넌트 파일)
- [ ] `useScreenMultiLang` 훅 사용
- [ ] `getTranslatedText`로 텍스트 번역 적용
### 키 수집 (ScreenMultiLangContext.tsx)
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
### 설정 모달 (MultilangSettingsModal.tsx)
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
---
## 9. 관련 파일 목록
| 파일 | 역할 |
| -------------------------------------------------------------- | ----------------------- |
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
---
## 10. 주의사항
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
- 제목: `${comp.id}_title`
- 컬럼: `${comp.id}_col_${index}`
- 버튼: `${comp.id}_button`
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트

70
PLAN.MD
View File

@ -1,4 +1,72 @@
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
## 개요
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 기능
### 1. 단일 화면 복제
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
- [x] 연결된 모달 화면 함께 복제
- [x] 대상 그룹 선택 가능
- [x] 복제 후 목록 자동 새로고침
### 2. 그룹(폴더) 전체 복제
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
- [x] 정렬 순서(display_order) 유지
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
- [x] 정렬 순서 입력 필드 추가
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
### 3. 고급 옵션: 이름 일괄 변경
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
- [x] 미리보기 기능
### 4. 삭제 기능
- [x] 단일 화면 삭제 (휴지통으로 이동)
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
- [x] 삭제 시 로딩 프로그레스 바 표시
### 5. 화면 수정 기능
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
### 6. 테이블 설정 기능 (TableSettingModal)
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
- 코드→다른 타입: codeCategory, codeValue 초기화
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
### 7. 회사 코드 지원 (최고 관리자)
- [x] 대상 회사 선택 가능
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
## 관련 파일
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
- `frontend/lib/api/screen.ts` - 화면 API
- `frontend/lib/api/screenGroup.ts` - 그룹 API
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
## 진행 상태
- [완료] 단일 화면 복제 + 새로고침
- [완료] 그룹 전체 복제 (재귀적)
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
- [완료] 화면 수정 (이름/그룹/역할/순서)
- [완료] 테이블 설정 탭 추가
- [완료] 입력 타입 변경 시 관련 필드 초기화
- [완료] 그룹 복제 모달 스크롤 문제 수정
---
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.

View File

@ -1044,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -2371,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3474,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3710,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3927,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4453,6 +4458,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -5663,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -7425,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8394,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -9283,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -10133,7 +10141,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10942,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11047,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -73,6 +73,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@ -197,6 +198,7 @@ app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);

View File

@ -553,10 +553,24 @@ export const setUserLocale = async (
const { locale } = req.body;
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
if (!locale) {
res.status(400).json({
success: false,
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
message: "로케일이 필요합니다.",
});
return;
}
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
success: false,
message: `유효하지 않은 로케일입니다: ${locale}`,
});
return;
}
@ -1165,6 +1179,33 @@ export async function saveMenu(
logger.info("메뉴 저장 성공", { savedMenu });
// 다국어 메뉴 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
// 메뉴 경로 조회 및 카테고리 생성
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
logger.info("메뉴 다국어 카테고리 생성 완료", {
menuObjId: savedMenu.objid.toString(),
menuPath,
});
} catch (categoryError) {
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
menuObjId: savedMenu.objid.toString(),
error: categoryError,
});
}
const response: ApiResponse<any> = {
success: true,
message: "메뉴가 성공적으로 저장되었습니다.",
@ -1376,6 +1417,75 @@ export async function updateMenu(
}
}
/**
* ID를
*/
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
const allIds: number[] = [];
// 직접 자식 메뉴들 조회
const children = await query<any>(
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
[parentObjid]
);
for (const child of children) {
allIds.push(child.objid);
// 자식의 자식들도 재귀적으로 수집
const grandChildren = await collectAllChildMenuIds(child.objid);
allIds.push(...grandChildren);
}
return allIds;
}
/**
*
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 2. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 3. code_info에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
[menuObjid]
);
// 6. screen_menu_assignments에서 관련 할당 삭제
await query(
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
[menuObjid]
);
// 7. screen_groups에서 menu_objid를 NULL로 설정
await query(
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
}
/**
*
*/
@ -1402,7 +1512,7 @@ export async function deleteMenu(
// 삭제하려는 메뉴 조회
const currentMenu = await queryOne<any>(
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);
@ -1437,67 +1547,50 @@ export async function deleteMenu(
}
}
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
const menuObjid = Number(menuId);
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(menuObjid);
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
// 2. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, {
menuName: currentMenu.menu_name_kor,
totalCount: allMenuIdsToDelete.length,
childMenuIds,
});
// 3. code_info에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
logger.info("메뉴 관련 데이터 정리 완료", {
menuObjid,
totalCleaned: allMenuIdsToDelete.length
});
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
[menuObjid]
);
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allMenuIdsToDelete].reverse();
// 6. screen_menu_assignments에서 관련 할당 삭제
await query(
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
[menuObjid]
);
for (const objid of reversedIds) {
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
}
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
// Raw Query를 사용한 메뉴 삭제
const [deletedMenu] = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid]
);
logger.info("메뉴 삭제 성공", { deletedMenu });
logger.info("메뉴 삭제 성공", {
deletedMenuObjid: menuObjid,
deletedMenuName: currentMenu.menu_name_kor,
totalDeleted: allMenuIdsToDelete.length,
});
const response: ApiResponse<any> = {
success: true,
message: "메뉴가 성공적으로 삭제되었습니다.",
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
data: {
objid: deletedMenu.objid.toString(),
menuNameKor: deletedMenu.menu_name_kor,
menuNameEng: deletedMenu.menu_name_eng,
menuUrl: deletedMenu.menu_url,
menuDesc: deletedMenu.menu_desc,
status: deletedMenu.status,
writer: deletedMenu.writer,
regdate: new Date(deletedMenu.regdate).toISOString(),
objid: menuObjid.toString(),
menuNameKor: currentMenu.menu_name_kor,
deletedCount: allMenuIdsToDelete.length,
deletedChildCount: childMenuIds.length,
},
};
@ -1582,18 +1675,49 @@ export async function deleteMenusBatch(
}
}
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
const allMenuIdsToDelete = new Set<number>();
for (const menuId of menuIds) {
const objid = Number(menuId);
allMenuIdsToDelete.add(objid);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(objid);
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
}
const allIdsArray = Array.from(allMenuIdsToDelete);
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}`, {
selectedMenuIds: menuIds,
totalWithChildren: allIdsArray.length,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allIdsArray) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
totalCleaned: allIdsArray.length
});
// Raw Query를 사용한 메뉴 일괄 삭제
let deletedCount = 0;
let failedCount = 0;
const deletedMenus: any[] = [];
const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도
for (const menuId of menuIds) {
for (const menuObjid of reversedIds) {
try {
const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[Number(menuId)]
[menuObjid]
);
if (result.length > 0) {
@ -1604,20 +1728,20 @@ export async function deleteMenusBatch(
});
} else {
failedCount++;
failedMenuIds.push(menuId);
failedMenuIds.push(String(menuObjid));
}
} catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
failedCount++;
failedMenuIds.push(menuId);
failedMenuIds.push(String(menuObjid));
}
}
logger.info("메뉴 일괄 삭제 완료", {
total: menuIds.length,
requested: menuIds.length,
totalWithChildren: allIdsArray.length,
deletedCount,
failedCount,
deletedMenus,
failedMenuIds,
});
@ -2649,6 +2773,24 @@ export const createCompany = async (
});
}
// 다국어 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
await multilangService.ensureCompanyCategory(
createdCompany.company_code,
createdCompany.company_name
);
logger.info("회사 다국어 카테고리 생성 완료", {
companyCode: createdCompany.company_code,
});
} catch (categoryError) {
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: categoryError,
});
}
logger.info("회사 등록 성공", {
companyCode: createdCompany.company_code,
companyName: createdCompany.company_name,
@ -3058,6 +3200,23 @@ export const updateProfile = async (
}
if (locale !== undefined) {
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
result: false,
error: {
code: "INVALID_LOCALE",
details: `유효하지 않은 로케일입니다: ${locale}`,
},
});
return;
}
updateFields.push(`locale = $${paramIndex}`);
updateValues.push(locale);
paramIndex++;

View File

@ -231,7 +231,7 @@ export const deleteFormData = async (
try {
const { id } = req.params;
const { companyCode, userId } = req.user as any;
const { tableName } = req.body;
const { tableName, screenId } = req.body;
if (!tableName) {
return res.status(400).json({
@ -240,7 +240,16 @@ export const deleteFormData = async (
});
}
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
await dynamicFormService.deleteFormData(
id,
tableName,
companyCode,
userId,
parsedScreenId // screenId 추가 (제어관리 실행용)
);
res.json({
success: true,

View File

@ -30,7 +30,6 @@ export class EntityJoinController {
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@ -67,11 +66,23 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField];
if (userValue) {
searchConditions[filterColumn] = userValue;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn,
userValue,
finalCompanyCode,
tableName,
});
}
@ -140,24 +151,6 @@ export class EntityJoinController {
}
}
// 🆕 중복 제거 설정 처리
let parsedDeduplication: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
} | undefined = undefined;
if (deduplication) {
try {
parsedDeduplication =
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
} catch (error) {
logger.warn("중복 제거 설정 파싱 오류:", error);
parsedDeduplication = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins(
tableName,
{
@ -175,26 +168,13 @@ export class EntityJoinController {
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
}
);
// 🆕 중복 제거 처리 (결과 데이터에 적용)
let finalData = result;
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
const originalCount = result.data.length;
finalData = {
...result,
data: this.deduplicateData(result.data, parsedDeduplication),
};
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}`);
}
res.status(200).json({
success: true,
message: "Entity 조인 데이터 조회 성공",
data: finalData,
data: result,
});
} catch (error) {
logger.error("Entity 조인 데이터 조회 실패", error);
@ -569,98 +549,6 @@ export class EntityJoinController {
});
}
}
/**
* ( )
*/
private deduplicateData(
data: any[],
config: {
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): any[] {
if (!data || data.length === 0) return data;
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal > bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal < bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "base_price":
// base_price가 true인 행 선택
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
break;
case "current_date":
// 오늘 날짜 기준 유효 기간 내 행 선택
const today = new Date().toISOString().split("T")[0];
selectedRow = rows.find((r) => {
const startDate = r.start_date;
const endDate = r.end_date;
if (!startDate) return true;
if (startDate <= today && (!endDate || endDate >= today)) return true;
return false;
}) || rows[0];
break;
default:
selectedRow = rows[0];
}
if (selectedRow) {
result.push(selectedRow);
}
}
return result;
}
}
export const entityJoinController = new EntityJoinController();

View File

@ -10,7 +10,10 @@ import {
SaveLangTextsRequest,
GetUserTextParams,
BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse,
LangCategory,
} from "../types/multilang";
/**
@ -187,7 +190,7 @@ export const getLangKeys = async (
res: Response
): Promise<void> => {
try {
const { companyCode, menuCode, keyType, searchText } = req.query;
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
logger.info("다국어 키 목록 조회 요청", {
query: req.query,
user: req.user,
@ -199,6 +202,7 @@ export const getLangKeys = async (
menuCode: menuCode as string,
keyType: keyType as string,
searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
});
const response: ApiResponse<any[]> = {
@ -630,6 +634,391 @@ export const deleteLanguage = async (
}
};
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
* GET /api/multilang/categories
* API ( )
*/
export const getCategories = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("카테고리 목록 조회 요청", { user: req.user });
const multiLangService = new MultiLangService();
const categories = await multiLangService.getCategories();
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 목록 조회 성공",
data: categories,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId
* API
*/
export const getCategoryById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const category = await multiLangService.getCategoryById(parseInt(categoryId));
if (!category) {
res.status(404).json({
success: false,
message: "카테고리를 찾을 수 없습니다.",
error: {
code: "CATEGORY_NOT_FOUND",
details: `Category ID ${categoryId} not found`,
},
});
return;
}
const response: ApiResponse<LangCategory> = {
success: true,
message: "카테고리 상세 조회 성공",
data: category,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_DETAIL_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId/path
* API ( )
*/
export const getCategoryPath = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 경로 조회 성공",
data: path,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 경로 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_PATH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
// =====================================================
// 자동 생성 및 오버라이드 관련 API
// =====================================================
/**
* POST /api/multilang/keys/generate
* API
*/
export const generateKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const generateData: GenerateKeyRequest = req.body;
logger.info("키 자동 생성 요청", { generateData, user: req.user });
// 필수 입력값 검증
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
res.status(400).json({
success: false,
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode, categoryId, and keyMeaning are required",
},
});
return;
}
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Only super admin can create common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 키만 생성 가능
if (generateData.companyCode !== "*" &&
req.user?.companyCode !== "*" &&
generateData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.generateKey({
...generateData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("키 자동 생성 실패:", error);
res.status(500).json({
success: false,
message: "키 자동 생성 중 오류가 발생했습니다.",
error: {
code: "KEY_GENERATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/preview
* API
*/
export const previewKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId, keyMeaning, companyCode } = req.body;
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
if (!categoryId || !keyMeaning || !companyCode) {
res.status(400).json({
success: false,
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "categoryId, keyMeaning, and companyCode are required",
},
});
return;
}
const multiLangService = new MultiLangService();
const preview = await multiLangService.previewGeneratedKey(
parseInt(categoryId),
keyMeaning,
companyCode
);
const response: ApiResponse<{
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}> = {
success: true,
message: "키 미리보기 성공",
data: preview,
};
res.status(200).json(response);
} catch (error) {
logger.error("키 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "키 미리보기 중 오류가 발생했습니다.",
error: {
code: "KEY_PREVIEW_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/override
* API
*/
export const createOverrideKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const overrideData: CreateOverrideKeyRequest = req.body;
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
// 필수 입력값 검증
if (!overrideData.companyCode || !overrideData.baseKeyId) {
res.status(400).json({
success: false,
message: "회사 코드와 원본 키 ID는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode and baseKeyId are required",
},
});
return;
}
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
if (overrideData.companyCode === "*") {
res.status(400).json({
success: false,
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
error: {
code: "INVALID_OVERRIDE",
details: "Cannot create override for common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
if (req.user?.companyCode !== "*" &&
overrideData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.createOverrideKey({
...overrideData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "오버라이드 키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("오버라이드 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEY_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/keys/overrides/:companyCode
* API
*/
export const getOverrideKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot view override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keys = await multiLangService.getOverrideKeys(companyCode);
const response: ApiResponse<any[]> = {
success: true,
message: "오버라이드 키 목록 조회 성공",
data: keys,
};
res.status(200).json(response);
} catch (error) {
logger.error("오버라이드 키 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEYS_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/batch
* API
@ -710,3 +1099,86 @@ export const getBatchTranslations = async (
});
}
};
/**
* POST /api/multilang/screen-labels
* API
*/
export const generateScreenLabelKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, menuObjId, labels } = req.body;
logger.info("화면 라벨 다국어 키 생성 요청", {
screenId,
menuObjId,
labelCount: labels?.length,
user: req.user,
});
// 필수 파라미터 검증
if (!screenId) {
res.status(400).json({
success: false,
message: "screenId는 필수입니다.",
error: { code: "MISSING_SCREEN_ID" },
});
return;
}
if (!labels || !Array.isArray(labels) || labels.length === 0) {
res.status(400).json({
success: false,
message: "labels 배열이 필요합니다.",
error: { code: "MISSING_LABELS" },
});
return;
}
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
const { queryOne } = await import("../database/db");
const screenInfo = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
const multiLangService = new MultiLangService();
const results = await multiLangService.generateScreenLabelKeys({
screenId: Number(screenId),
companyCode,
companyName,
menuObjId,
labels,
});
const response: ApiResponse<typeof results> = {
success: true,
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
data: results,
};
res.status(200).json(response);
} catch (error) {
logger.error("화면 라벨 다국어 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};

File diff suppressed because it is too large Load Diff

View File

@ -775,18 +775,25 @@ export async function getTableData(
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField];
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
if (userValue && userValue !== "*") {
enhancedSearch[filterColumn] = userValue;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue,
tableName,
});
} else if (userValue === "*") {
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
userValue: finalCompanyCode,
tableName,
});
} else {
@ -797,9 +804,6 @@ export async function getTableData(
}
}
// 🆕 최종 검색 조건 로그
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
@ -901,23 +905,13 @@ export async function addTableData(
}
// 데이터 추가
const result = await tableManagementService.addTableData(tableName, data);
await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
// 무시된 컬럼이 있으면 경고 정보 포함
const response: ApiResponse<{
skippedColumns?: string[];
savedColumns?: string[];
}> = {
const response: ApiResponse<null> = {
success: true,
message: result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
data: {
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
savedColumns: result.savedColumns,
},
message: "테이블 데이터를 성공적으로 추가했습니다.",
};
res.status(201).json(response);
@ -2186,11 +2180,8 @@ export async function multiTableSave(
}
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*
* column_labels의 entity/category
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
@ -2199,53 +2190,93 @@ export async function getTableEntityRelations(
try {
const { leftTable, rightTable } = req.query;
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
if (!leftTable || !rightTable) {
const response: ApiResponse<null> = {
res.status(400).json({
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
});
return;
}
const tableManagementService = new TableManagementService();
const relations = await tableManagementService.detectTableEntityRelations(
String(leftTable),
String(rightTable)
);
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
// 두 테이블의 컬럼 라벨 정보 조회
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
web_type,
detail_settings
FROM column_labels
WHERE table_name IN ($1, $2)
AND web_type IN ('entity', 'category')
`;
const response: ApiResponse<any> = {
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: {
leftTable: String(leftTable),
rightTable: String(rightTable),
leftTable,
rightTable,
relations,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
error: {
code: "ENTITY_RELATIONS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
}
}

View File

@ -56,3 +56,4 @@ export default router;

View File

@ -52,3 +52,4 @@ export default router;

View File

@ -68,3 +68,4 @@ export default router;

View File

@ -56,3 +56,4 @@ export default router;

View File

@ -1,10 +1,262 @@
import express from "express";
import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
const router = express.Router();
// ================================
// 마스터-디테일 엑셀 API
// ================================
/**
* -
* GET /api/data/master-detail/relation/:screenId
*/
router.get(
"/master-detail/relation/:screenId",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId } = req.params;
if (!screenId || isNaN(parseInt(screenId))) {
return res.status(400).json({
success: false,
message: "유효한 screenId가 필요합니다.",
});
}
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.json({
success: true,
data: null,
message: "마스터-디테일 구조가 아닙니다.",
});
}
console.log(`✅ 마스터-디테일 관계 발견:`, {
masterTable: relation.masterTable,
detailTable: relation.detailTable,
joinKey: relation.masterKeyColumn,
});
return res.json({
success: true,
data: relation,
});
} catch (error: any) {
console.error("마스터-디테일 관계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* POST /api/data/master-detail/download
*/
router.post(
"/master-detail/download",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, filters } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!screenId) {
return res.status(400).json({
success: false,
message: "screenId가 필요합니다.",
});
}
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. JOIN 데이터 조회
const data = await masterDetailExcelService.getJoinedData(
relation,
companyCode,
filters
);
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}`);
return res.json({
success: true,
data,
});
} catch (error: any) {
console.error("마스터-디테일 다운로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* POST /api/data/master-detail/upload
*/
router.post(
"/master-detail/upload",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, data } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!screenId || !data || !Array.isArray(data)) {
return res.status(400).json({
success: false,
message: "screenId와 data 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. 데이터 업로드
const result = await masterDetailExcelService.uploadJoinedData(
relation,
data,
companyCode,
userId
);
console.log(`✅ 마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* - UI에서
* -
* -
*
* POST /api/data/master-detail/upload-simple
*/
router.post(
"/master-detail/upload-simple",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
if (!screenId || !detailData || !Array.isArray(detailData)) {
return res.status(400).json({
success: false,
message: "screenId와 detailData 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
console.log(` 마스터 필드 값:`, masterFieldValues);
console.log(` 채번 규칙 ID:`, numberingRuleId);
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}` : afterUploadFlowId || "없음");
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
parseInt(screenId),
detailData,
masterFieldValues || {},
numberingRuleId,
companyCode,
userId,
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
afterUploadFlows // 업로드 후 제어 실행 (다중)
);
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
// ================================
// 기존 데이터 API
// ================================
/**
* API ( )
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...

View File

@ -21,6 +21,20 @@ import {
getUserText,
getLangText,
getBatchTranslations,
// 카테고리 관리 API
getCategories,
getCategoryById,
getCategoryPath,
// 자동 생성 및 오버라이드 API
generateKey,
previewKey,
createOverrideKey,
getOverrideKeys,
// 화면 라벨 다국어 API
generateScreenLabelKeys,
} from "../controllers/multilangController";
const router = express.Router();
@ -51,4 +65,18 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
// 카테고리 관리 API
router.get("/categories", getCategories); // 카테고리 트리 조회
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
// 자동 생성 및 오버라이드 API
router.post("/keys/generate", generateKey); // 키 자동 생성
router.post("/keys/preview", previewKey); // 키 미리보기
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
// 화면 라벨 다국어 자동 생성 API
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
export default router;

View File

@ -0,0 +1,111 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
// 화면 그룹
getScreenGroups,
getScreenGroup,
createScreenGroup,
updateScreenGroup,
deleteScreenGroup,
// 화면-그룹 연결
addScreenToGroup,
removeScreenFromGroup,
updateScreenInGroup,
// 필드 조인
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
// 데이터 흐름
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
// 화면-테이블 관계
getTableRelations,
createTableRelation,
updateTableRelation,
deleteTableRelation,
// 화면 레이아웃 요약
getScreenLayoutSummary,
getMultipleScreenLayoutSummary,
// 화면 서브 테이블 관계
getScreenSubTables,
// 메뉴-화면그룹 동기화
syncScreenGroupsToMenuController,
syncMenuToScreenGroupsController,
getSyncStatusController,
syncAllCompaniesController,
} from "../controllers/screenGroupController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ============================================================
// 화면 그룹 (screen_groups)
// ============================================================
router.get("/groups", getScreenGroups);
router.get("/groups/:id", getScreenGroup);
router.post("/groups", createScreenGroup);
router.put("/groups/:id", updateScreenGroup);
router.delete("/groups/:id", deleteScreenGroup);
// ============================================================
// 화면-그룹 연결 (screen_group_screens)
// ============================================================
router.post("/group-screens", addScreenToGroup);
router.put("/group-screens/:id", updateScreenInGroup);
router.delete("/group-screens/:id", removeScreenFromGroup);
// ============================================================
// 필드 조인 설정 (screen_field_joins)
// ============================================================
router.get("/field-joins", getFieldJoins);
router.post("/field-joins", createFieldJoin);
router.put("/field-joins/:id", updateFieldJoin);
router.delete("/field-joins/:id", deleteFieldJoin);
// ============================================================
// 데이터 흐름 (screen_data_flows)
// ============================================================
router.get("/data-flows", getDataFlows);
router.post("/data-flows", createDataFlow);
router.put("/data-flows/:id", updateDataFlow);
router.delete("/data-flows/:id", deleteDataFlow);
// ============================================================
// 화면-테이블 관계 (screen_table_relations)
// ============================================================
router.get("/table-relations", getTableRelations);
router.post("/table-relations", createTableRelation);
router.put("/table-relations/:id", updateTableRelation);
router.delete("/table-relations/:id", deleteTableRelation);
// ============================================================
// 화면 레이아웃 요약 (미리보기용)
// ============================================================
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
// ============================================================
// 화면 서브 테이블 관계 (조인/참조 테이블)
// ============================================================
router.post("/sub-tables/batch", getScreenSubTables);
// ============================================================
// 메뉴-화면그룹 동기화
// ============================================================
// 동기화 상태 조회
router.get("/sync/status", getSyncStatusController);
// 화면관리 → 메뉴 동기화
router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController);
// 메뉴 → 화면관리 동기화
router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
// 전체 회사 동기화 (최고 관리자만)
router.post("/sync/all", syncAllCompaniesController);
export default router;

View File

@ -254,7 +254,10 @@ class DataService {
key !== "limit" &&
key !== "offset" &&
key !== "orderBy" &&
key !== "userLang"
key !== "userLang" &&
key !== "page" &&
key !== "pageSize" &&
key !== "size"
) {
// 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {

View File

@ -1192,12 +1192,18 @@ export class DynamicFormService {
/**
* ( )
* @param id ID
* @param tableName
* @param companyCode
* @param userId ID
* @param screenId ID ( , )
*/
async deleteFormData(
id: string | number,
tableName: string,
companyCode?: string,
userId?: string
userId?: string,
screenId?: number
): Promise<void> {
try {
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
@ -1310,14 +1316,19 @@ export class DynamicFormService {
const recordCompanyCode =
deletedRecord?.company_code || companyCode || "*";
await this.executeDataflowControlIfConfigured(
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
tableName,
deletedRecord,
"delete",
userId || "system",
recordCompanyCode
);
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
if (screenId && screenId > 0) {
await this.executeDataflowControlIfConfigured(
screenId,
tableName,
deletedRecord,
"delete",
userId || "system",
recordCompanyCode
);
} else {
console.log(" screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
}
}
} catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError);
@ -1662,10 +1673,16 @@ export class DynamicFormService {
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
});
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
const buttonActionType = properties?.componentConfig?.action?.type;
const isMatchingAction =
(triggerType === "delete" && buttonActionType === "delete") ||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
if (
properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" &&
isMatchingAction &&
properties?.webTypeConfig?.enableDataflowControl === true
) {
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;

View File

@ -0,0 +1,908 @@
/**
* -
*
* -
* / JOIN .
*/
import { query, queryOne, transaction, getPool } from "../database/db";
import { logger } from "../utils/logger";
// ================================
// 인터페이스 정의
// ================================
/**
* -
*/
export interface MasterDetailRelation {
masterTable: string;
detailTable: string;
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
masterColumns: ColumnInfo[];
detailColumns: ColumnInfo[];
}
/**
*
*/
export interface ColumnInfo {
name: string;
label: string;
inputType: string;
isFromMaster: boolean;
}
/**
*
*/
export interface SplitPanelConfig {
leftPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
};
rightPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
relation?: {
type: string;
foreignKey?: string;
leftColumn?: string;
// 복합키 지원 (새로운 방식)
keys?: Array<{
leftColumn: string;
rightColumn: string;
}>;
};
};
}
/**
*
*/
export interface ExcelDownloadData {
headers: string[]; // 컬럼 라벨들
columns: string[]; // 컬럼명들
data: Record<string, any>[];
masterColumns: string[]; // 마스터 컬럼 목록
detailColumns: string[]; // 디테일 컬럼 목록
joinKey: string; // 조인 키
}
/**
*
*/
export interface ExcelUploadResult {
success: boolean;
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailDeleted: number;
errors: string[];
}
// ================================
// 서비스 클래스
// ================================
class MasterDetailExcelService {
/**
* ID로
*/
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
try {
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
const result = await queryOne<any>(
`SELECT properties->>'componentConfig' as config
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties->>'componentType' = 'split-panel-layout'
LIMIT 1`,
[screenId]
);
if (!result || !result.config) {
logger.info(`분할 패널 없음: screenId=${screenId}`);
return null;
}
const config = typeof result.config === "string"
? JSON.parse(result.config)
: result.config;
logger.info(`분할 패널 설정 발견:`, {
leftTable: config.leftPanel?.tableName,
rightTable: config.rightPanel?.tableName,
relation: config.rightPanel?.relation,
});
return {
leftPanel: config.leftPanel,
rightPanel: config.rightPanel,
};
} catch (error: any) {
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
return null;
}
}
/**
* column_labels에서 Entity
*
*/
async getEntityRelation(
detailTable: string,
masterTable: string
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
try {
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
const result = await queryOne<any>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
LIMIT 1`,
[detailTable, masterTable]
);
if (!result) {
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
return null;
}
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
return {
detailFkColumn: result.column_name,
masterKeyColumn: result.reference_column,
};
} catch (error: any) {
logger.error(`Entity 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
*
*/
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
try {
const result = await query<any>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
const labelMap = new Map<string, string>();
for (const row of result) {
labelMap.set(row.column_name, row.column_label || row.column_name);
}
return labelMap;
} catch (error: any) {
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
return new Map();
}
}
/**
* -
*/
async getMasterDetailRelation(
screenId: number
): Promise<MasterDetailRelation | null> {
try {
// 1. 분할 패널 설정 조회
const splitPanel = await this.getSplitPanelConfig(screenId);
if (!splitPanel) {
return null;
}
const masterTable = splitPanel.leftPanel.tableName;
const detailTable = splitPanel.rightPanel.tableName;
if (!masterTable || !detailTable) {
logger.warn("마스터 또는 디테일 테이블명 없음");
return null;
}
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
let masterKeyColumn: string | undefined;
let detailFkColumn: string | undefined;
const relationKeys = splitPanel.rightPanel.relation?.keys;
if (relationKeys && relationKeys.length > 0) {
// keys 배열에서 첫 번째 키 사용
masterKeyColumn = relationKeys[0].leftColumn;
detailFkColumn = relationKeys[0].rightColumn;
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
} else {
// 하위 호환성: 기존 leftColumn/foreignKey 사용
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
}
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) {
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
if (entityRelation) {
masterKeyColumn = entityRelation.masterKeyColumn;
detailFkColumn = entityRelation.detailFkColumn;
}
}
if (!masterKeyColumn || !detailFkColumn) {
logger.warn("조인 키 정보를 찾을 수 없음");
return null;
}
// 4. 컬럼 라벨 정보 조회
const masterLabels = await this.getColumnLabels(masterTable);
const detailLabels = await this.getColumnLabels(detailTable);
// 5. 마스터 컬럼 정보 구성
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
name: col.name,
label: masterLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: true,
}));
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
.map(col => ({
name: col.name,
label: detailLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: false,
}));
logger.info(`마스터-디테일 관계 구성 완료:`, {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumnCount: masterColumns.length,
detailColumnCount: detailColumns.length,
});
return {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumns,
detailColumns,
};
} catch (error: any) {
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
* - JOIN ( )
*/
async getJoinedData(
relation: MasterDetailRelation,
companyCode: string,
filters?: Record<string, any>
): Promise<ExcelDownloadData> {
try {
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 조인 컬럼과 일반 컬럼 분리
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
const entityJoins: Array<{
refTable: string;
refColumn: string;
sourceColumn: string;
alias: string;
displayColumn: string;
}> = [];
// SELECT 절 구성
const selectParts: string[] = [];
let aliasIndex = 0;
// 마스터 컬럼 처리
for (const col of masterColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
// FK를 못 찾으면 NULL로 처리
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`m."${col.name}"`);
}
}
// 디테일 컬럼 처리
for (const col of detailColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`d."${col.name}"`);
}
}
const selectClause = selectParts.join(", ");
// 엔티티 조인 절 구성
const entityJoinClauses = entityJoins.map(ej =>
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
).join("\n ");
// WHERE 절 구성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (최고 관리자 제외)
if (companyCode && companyCode !== "*") {
whereConditions.push(`m.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 추가 필터 적용
if (filters) {
for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== null && value !== "") {
// 조인 컬럼인지 확인
if (key.includes(".")) continue;
// 마스터 테이블 컬럼인지 확인
const isMasterCol = masterColumns.some(c => c.name === key);
const tableAlias = isMasterCol ? "m" : "d";
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
params.push(value);
paramIndex++;
}
}
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// JOIN 쿼리 실행
const sql = `
SELECT ${selectClause}
FROM "${masterTable}" m
LEFT JOIN "${detailTable}" d
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
AND m.company_code = d.company_code
${entityJoinClauses}
${whereClause}
ORDER BY m."${masterKeyColumn}", d.id
`;
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
const data = await query<any>(sql, params);
// 헤더 및 컬럼 정보 구성
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}`);
return {
headers,
columns,
data,
masterColumns: masterColumns.map(c => c.name),
detailColumns: detailColumns.map(c => c.name),
joinKey: masterKeyColumn,
};
} catch (error: any) {
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
throw error;
}
}
/**
* FK
*/
private async findForeignKeyColumn(
sourceTable: string,
referenceTable: string
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
try {
const result = await query<{ column_name: string; reference_column: string }>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND reference_table = $2
AND input_type = 'entity'
LIMIT 1`,
[sourceTable, referenceTable]
);
if (result.length > 0) {
return {
sourceColumn: result[0].column_name,
referenceColumn: result[0].reference_column,
};
}
return null;
} catch (error) {
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
return null;
}
}
/**
* - ( )
*
* :
* 1.
* 2. UPSERT
* 3.
* 4. INSERT
*/
async uploadJoinedData(
relation: MasterDetailRelation,
data: Record<string, any>[],
companyCode: string,
userId?: string
): Promise<ExcelUploadResult> {
const result: ExcelUploadResult = {
success: false,
masterInserted: 0,
masterUpdated: 0,
detailInserted: 0,
detailDeleted: 0,
errors: [],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 1. 데이터를 마스터 키로 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
}
groupedData.get(masterKey)!.push(row);
}
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
// 2. 각 그룹 처리
for (const [masterKey, rows] of groupedData.entries()) {
try {
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
const masterData: Record<string, any> = {};
for (const col of masterColumns) {
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
}
}
// 회사 코드, 작성자 추가
masterData.company_code = companyCode;
if (userId) {
masterData.writer = userId;
}
// 2b. 마스터 UPSERT
const existingMaster = await client.query(
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
if (existingMaster.rows.length > 0) {
// UPDATE
const updateCols = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map((k, i) => `"${k}" = $${i + 1}`);
const updateValues = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map(k => masterData[k]);
if (updateCols.length > 0) {
await client.query(
`UPDATE "${masterTable}"
SET ${updateCols.join(", ")}, updated_date = NOW()
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
[...updateValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// INSERT
const insertCols = Object.keys(masterData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.masterInserted++;
}
// 2c. 기존 디테일 삭제
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
// 2d. 새 디테일 INSERT
for (const row of rows) {
const detailData: Record<string, any> = {};
// FK 컬럼 추가
detailData[detailFkColumn] = masterKey;
detailData.company_code = companyCode;
if (userId) {
detailData.writer = userId;
}
// 디테일 컬럼 데이터 추출
for (const col of detailColumns) {
if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name];
}
}
const insertCols = Object.keys(detailData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => detailData[k]);
await client.query(
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.detailInserted++;
}
} catch (error: any) {
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
}
}
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
logger.info(`마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted,
errors: result.errors.length,
});
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* -
*
* UI에서 ,
*
*
* @param screenId ID
* @param detailData
* @param masterFieldValues UI에서
* @param numberingRuleId ID (optional)
* @param companyCode
* @param userId ID
* @param afterUploadFlowId ID (optional, )
* @param afterUploadFlows (optional)
*/
async uploadSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId: string | undefined,
companyCode: string,
userId: string,
afterUploadFlowId?: string,
afterUploadFlows?: Array<{ flowId: string; order: number }>
): Promise<{
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
}> {
const result: {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
} = {
success: false,
masterInserted: 0,
detailInserted: 0,
generatedKey: "",
errors: [] as string[],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 마스터-디테일 관계 정보 조회
const relation = await this.getMasterDetailRelation(screenId);
if (!relation) {
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
}
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
// 2. 채번 처리
let generatedKey: string;
if (numberingRuleId) {
// 채번 규칙으로 키 생성
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
} else {
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
generatedKey = masterFieldValues[masterKeyColumn];
if (!generatedKey) {
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
}
}
result.generatedKey = generatedKey;
logger.info(`채번 결과: ${generatedKey}`);
// 3. 마스터 레코드 생성
const masterData: Record<string, any> = {
...masterFieldValues,
[masterKeyColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 마스터 컬럼명 목록 구성
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
const masterValues = masterCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
masterValues
);
result.masterInserted = 1;
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
const insertedDetailRows: Record<string, any>[] = [];
for (const row of detailData) {
try {
const detailRowData: Record<string, any> = {
...row,
[detailFkColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 빈 값 필터링 및 id 제외
const detailCols = Object.keys(detailRowData).filter(k =>
k !== "id" &&
detailRowData[k] !== undefined &&
detailRowData[k] !== null &&
detailRowData[k] !== ""
);
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
const detailValues = detailCols.map(k => detailRowData[k]);
// RETURNING *로 삽입된 데이터 반환받기
const insertResult = await client.query(
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${detailPlaceholders.join(", ")}, NOW())
RETURNING *`,
detailValues
);
if (insertResult.rows && insertResult.rows[0]) {
insertedDetailRows.push(insertResult.rows[0]);
}
result.detailInserted++;
} catch (error: any) {
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
logger.error(`디테일 행 처리 실패:`, error);
}
}
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.detailInserted > 0;
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
// 업로드 후 제어 실행 (단일 또는 다중)
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
? afterUploadFlows // 다중 제어
: afterUploadFlowId
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
: [];
if (flowsToExecute.length > 0 && result.success) {
try {
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
// 마스터 데이터 구성
const masterData = {
...masterFieldValues,
[relation!.masterKeyColumn]: result.generatedKey,
company_code: companyCode,
};
const controlResults: any[] = [];
// 순서대로 제어 실행
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}`);
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
const controlResult = await NodeFlowExecutionService.executeFlow(
parseInt(flow.flowId),
{
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
buttonId: "excel-upload-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: masterData,
// 추가 컨텍스트: 마스터/디테일 정보
masterData: masterData,
detailData: insertedDetailRows,
masterTable: relation!.masterTable,
detailTable: relation!.detailTable,
masterKeyColumn: relation!.masterKeyColumn,
detailFkColumn: relation!.detailFkColumn,
}
);
controlResults.push({
flowId: flow.flowId,
order: flow.order,
success: controlResult.success,
message: controlResult.message,
executedNodes: controlResult.nodes?.length || 0,
});
}
result.controlResult = {
success: controlResults.every(r => r.success),
executedFlows: controlResults.length,
results: controlResults,
};
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
} catch (controlError: any) {
logger.error(`업로드 후 제어 실행 실패:`, controlError);
result.controlResult = {
success: false,
message: `제어 실행 실패: ${controlError.message}`,
};
}
}
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* ( numberingRuleService )
*/
private async generateNumberWithRule(
client: any,
ruleId: string,
companyCode: string
): Promise<string> {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import("./numberingRuleService");
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
return generatedCode;
} catch (error: any) {
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
throw error;
}
}
}
export const masterDetailExcelService = new MasterDetailExcelService();

View File

@ -2090,7 +2090,7 @@ export class MenuCopyService {
menu.menu_url,
menu.menu_desc,
userId,
menu.status,
'active', // 복제된 메뉴는 항상 활성화 상태
menu.system_name,
targetCompanyCode, // 새 회사 코드
menu.lang_key,

View File

@ -0,0 +1,969 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
/**
* -
*
* :
* 1. screen_groups menu_info: 화면관리
* 2. menu_info screen_groups: 사용자
*/
// ============================================================
// 타입 정의
// ============================================================
interface SyncResult {
success: boolean;
created: number;
linked: number;
skipped: number;
errors: string[];
details: SyncDetail[];
}
interface SyncDetail {
action: 'created' | 'linked' | 'skipped' | 'error';
sourceName: string;
sourceId: number | string;
targetId?: number | string;
reason?: string;
}
// ============================================================
// 화면관리 → 메뉴 동기화
// ============================================================
/**
* screen_groups를 menu_info로
*
* :
* 1. screen_groups ( )
* 2. menu_objid가
* 3. menu_info
* - 매칭되면: 양쪽에 ID
* - 안되면: menu_info에
* 4. (parent)
*/
export async function syncScreenGroupsToMenu(
companyCode: string,
userId: string
): Promise<SyncResult> {
const result: SyncResult = {
success: true,
created: 0,
linked: 0,
skipped: 0,
errors: [],
details: [],
};
const client = await pool.connect();
try {
await client.query('BEGIN');
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
const screenGroupsQuery = `
SELECT
sg.id,
sg.group_name,
sg.group_code,
sg.parent_group_id,
sg.group_level,
sg.display_order,
sg.description,
sg.icon,
sg.menu_objid,
-- menu_objid도 ( )
parent.menu_objid as parent_menu_objid
FROM screen_groups sg
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
WHERE sg.company_code = $1
ORDER BY sg.group_level ASC, sg.display_order ASC
`;
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
// 경로 기반 매칭을 위해 부모 이름도 조회
const existingMenusQuery = `
SELECT
m.objid,
m.menu_name_kor,
m.parent_obj_id,
m.screen_group_id,
p.menu_name_kor as parent_name
FROM menu_info m
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
WHERE m.company_code = $1 AND m.menu_type = 1
`;
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
// 단순 이름 매칭도 유지 (하위 호환)
const menuByPath: Map<string, any> = new Map();
const menuByName: Map<string, any> = new Map();
existingMenusResult.rows.forEach((menu: any) => {
if (!menu.screen_group_id) {
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
const parentName = menu.parent_name?.trim().toLowerCase() || '';
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
menuByPath.set(pathKey, menu);
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
if (!menuByName.has(menuName)) {
menuByName.set(menuName, menu);
}
}
});
// 모든 메뉴의 objid 집합 (삭제 확인용)
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
// 없으면 생성
let userMenuRootObjid: number | null = null;
const rootMenuQuery = `
SELECT objid FROM menu_info
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
ORDER BY seq ASC
LIMIT 1
`;
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
if (rootMenuResult.rows.length > 0) {
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
} else {
// 루트 메뉴가 없으면 생성
const newObjid = Date.now();
const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
RETURNING objid
`;
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
userMenuRootObjid = Number(createRootResult.rows[0].objid);
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
}
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
const groupToMenuMap: Map<number, number> = new Map();
// screen_groups의 부모 이름 조회를 위한 매핑
const groupIdToName: Map<number, string> = new Map();
screenGroupsResult.rows.forEach((g: any) => {
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
});
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
const topLevelCompanyFolderIds = new Set<number>();
for (const group of screenGroupsResult.rows) {
if (group.group_level === 0 && group.parent_group_id === null) {
topLevelCompanyFolderIds.add(group.id);
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
groupToMenuMap.set(group.id, userMenuRootObjid!);
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
}
}
// 6. 각 screen_group 처리
for (const group of screenGroupsResult.rows) {
const groupId = group.id;
const groupName = group.group_name?.trim();
const groupNameLower = groupName?.toLowerCase() || '';
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
if (topLevelCompanyFolderIds.has(groupId)) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
});
continue;
}
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
if (group.menu_objid) {
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
if (menuExists) {
// 메뉴가 존재하면 스킵
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
targetId: group.menu_objid,
reason: '이미 메뉴와 연결됨',
});
groupToMenuMap.set(groupId, Number(group.menu_objid));
continue;
} else {
// 메뉴가 삭제되었으면 연결 해제하고 재생성
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
await client.query(
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
[groupId]
);
// 계속 진행하여 재생성 또는 재연결
}
}
// 부모 그룹 이름 조회 (경로 기반 매칭용)
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
let matchedMenu = menuByPath.get(pathKey);
if (!matchedMenu) {
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
matchedMenu = menuByName.get(groupNameLower);
}
if (matchedMenu) {
// 매칭된 메뉴와 연결
const menuObjid = Number(matchedMenu.objid);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[menuObjid, groupId]
);
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[groupId, menuObjid]
);
groupToMenuMap.set(groupId, menuObjid);
result.linked++;
result.details.push({
action: 'linked',
sourceName: groupName,
sourceId: groupId,
targetId: menuObjid,
});
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
menuByPath.delete(pathKey);
menuByName.delete(groupNameLower);
} else {
// 새 메뉴 생성
const newObjid = Date.now() + groupId; // 고유 ID 보장
// 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
let parentMenuObjid = userMenuRootObjid;
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
} else if (group.parent_group_id && group.parent_menu_objid) {
// 기존 parent_menu_objid가 실제로 존재하는지 확인
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
if (parentMenuExists) {
parentMenuObjid = Number(group.parent_menu_objid);
}
}
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
let nextSeq = 1;
const maxSeqQuery = `
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
FROM menu_info
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
`;
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
if (maxSeqResult.rows.length > 0) {
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
}
// menu_info에 삽입
const insertMenuQuery = `
INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
RETURNING objid
`;
await client.query(insertMenuQuery, [
newObjid,
parentMenuObjid,
groupName,
group.group_code || groupName,
nextSeq,
companyCode,
userId,
groupId,
group.description || null,
]);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[newObjid, groupId]
);
groupToMenuMap.set(groupId, newObjid);
result.created++;
result.details.push({
action: 'created',
sourceName: groupName,
sourceId: groupId,
targetId: newObjid,
});
}
}
await client.query('COMMIT');
logger.info("화면관리 → 메뉴 동기화 완료", {
companyCode,
created: result.created,
linked: result.linked,
skipped: result.skipped
});
return result;
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
result.success = false;
result.errors.push(error.message);
return result;
} finally {
client.release();
}
}
// ============================================================
// 메뉴 → 화면관리 동기화
// ============================================================
/**
* menu_info를 screen_groups로
*
* :
* 1. (menu_type=1)
* 2. screen_group_id가
* 3. screen_groups
* - 매칭되면: 양쪽에 ID
* - 안되면: screen_groups에 ()
* 4. (parent)
*/
export async function syncMenuToScreenGroups(
companyCode: string,
userId: string
): Promise<SyncResult> {
const result: SyncResult = {
success: true,
created: 0,
linked: 0,
skipped: 0,
errors: [],
details: [],
};
const client = await pool.connect();
try {
await client.query('BEGIN');
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
const menusQuery = `
SELECT
m.objid,
m.menu_name_kor,
m.menu_name_eng,
m.parent_obj_id,
m.seq,
m.menu_url,
m.menu_desc,
m.screen_group_id,
-- screen_group_id도 ( )
parent.screen_group_id as parent_screen_group_id
FROM menu_info m
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
WHERE m.company_code = $1 AND m.menu_type = 1
ORDER BY
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
m.parent_obj_id,
m.seq
`;
const menusResult = await client.query(menusQuery, [companyCode]);
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
const existingGroupsQuery = `
SELECT
g.id,
g.group_name,
g.menu_objid,
g.parent_group_id,
p.group_name as parent_name
FROM screen_groups g
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
WHERE g.company_code = $1
`;
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
// 단순 이름 매칭도 유지 (하위 호환)
const groupByPath: Map<string, any> = new Map();
const groupByName: Map<string, any> = new Map();
existingGroupsResult.rows.forEach((group: any) => {
if (!group.menu_objid) {
const groupName = group.group_name?.trim().toLowerCase() || '';
const parentName = group.parent_name?.trim().toLowerCase() || '';
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
groupByPath.set(pathKey, group);
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
if (!groupByName.has(groupName)) {
groupByName.set(groupName, group);
}
}
});
// 모든 그룹의 id 집합 (삭제 확인용)
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
let companyFolderId: number | null = null;
const companyFolderQuery = `
SELECT id FROM screen_groups
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
ORDER BY id ASC
LIMIT 1
`;
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
if (companyFolderResult.rows.length > 0) {
companyFolderId = companyFolderResult.rows[0].id;
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
} else {
// 회사 폴더가 없으면 생성
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
let nextRootOrder = 1;
const maxRootOrderQuery = `
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
FROM screen_groups
WHERE parent_group_id IS NULL
`;
const maxRootOrderResult = await client.query(maxRootOrderQuery);
if (maxRootOrderResult.rows.length > 0) {
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
}
const createFolderQuery = `
INSERT INTO screen_groups (
group_name, group_code, parent_group_id, group_level,
display_order, company_code, writer, hierarchy_path
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
RETURNING id
`;
const createFolderResult = await client.query(createFolderQuery, [
companyName,
companyCode.toLowerCase(),
nextRootOrder,
companyCode,
userId,
]);
companyFolderId = createFolderResult.rows[0].id;
// hierarchy_path 업데이트
await client.query(
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
[`/${companyFolderId}/`, companyFolderId]
);
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
}
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
const menuToGroupMap: Map<number, number> = new Map();
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
menusResult.rows.forEach((menu: any) => {
if (menu.screen_group_id) {
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
}
});
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
let rootMenuObjid: number | null = null;
for (const menu of menusResult.rows) {
if (Number(menu.parent_obj_id) === 0) {
rootMenuObjid = Number(menu.objid);
// 루트 메뉴는 회사 폴더와 연결
if (companyFolderId) {
menuToGroupMap.set(rootMenuObjid, companyFolderId);
}
break;
}
}
// 5. 각 메뉴 처리
for (const menu of menusResult.rows) {
const menuObjid = Number(menu.objid);
const menuName = menu.menu_name_kor?.trim();
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
if (Number(menu.parent_obj_id) === 0) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: menuName,
sourceId: menuObjid,
targetId: companyFolderId || undefined,
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
});
continue;
}
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
if (menu.screen_group_id) {
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
if (groupExists) {
// 그룹이 존재하면 스킵
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: menuName,
sourceId: menuObjid,
targetId: menu.screen_group_id,
reason: '이미 화면그룹과 연결됨',
});
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
continue;
} else {
// 그룹이 삭제되었으면 연결 해제하고 재생성
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
await client.query(
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
[menuObjid]
);
// 계속 진행하여 재생성 또는 재연결
}
}
const menuNameLower = menuName?.toLowerCase() || '';
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
let matchedGroup = groupByPath.get(pathKey);
if (!matchedGroup) {
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
matchedGroup = groupByName.get(menuNameLower);
}
if (matchedGroup) {
// 매칭된 그룹과 연결
const groupId = Number(matchedGroup.id);
try {
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[groupId, menuObjid]
);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[menuObjid, groupId]
);
menuToGroupMap.set(menuObjid, groupId);
result.linked++;
result.details.push({
action: 'linked',
sourceName: menuName,
sourceId: menuObjid,
targetId: groupId,
});
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
groupByPath.delete(pathKey);
groupByName.delete(menuNameLower);
} catch (linkError: any) {
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
throw linkError;
}
} else {
// 새 screen_group 생성
// 부모 그룹 ID 결정
let parentGroupId: number | null = null;
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
}
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
parentGroupId = companyFolderId;
}
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
parentGroupId = Number(menu.parent_screen_group_id);
}
// 부모 그룹의 레벨 조회
if (parentGroupId) {
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
if (parentLevelResult.rows.length > 0) {
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
}
}
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
let nextDisplayOrder = 1;
const maxOrderQuery = parentGroupId
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
if (maxOrderResult.rows.length > 0) {
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
}
// group_code 생성 (영문명 또는 이름 기반)
const groupCode = (menu.menu_name_eng || menuName || 'group')
.replace(/\s+/g, '_')
.toLowerCase()
.substring(0, 50);
// screen_groups에 삽입
const insertGroupQuery = `
INSERT INTO screen_groups (
group_name, group_code, parent_group_id, group_level,
display_order, company_code, writer, menu_objid, description
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`;
let newGroupId: number;
try {
logger.info("새 그룹 생성 시도", {
menuName,
menuObjid,
groupCode: groupCode + '_' + menuObjid,
parentGroupId,
groupLevel,
nextDisplayOrder,
companyCode,
});
const insertResult = await client.query(insertGroupQuery, [
menuName,
groupCode + '_' + menuObjid, // 고유성 보장
parentGroupId,
groupLevel,
nextDisplayOrder,
companyCode,
userId,
menuObjid,
menu.menu_desc || null,
]);
newGroupId = insertResult.rows[0].id;
} catch (insertError: any) {
logger.error("그룹 생성 중 에러", {
menuName,
menuObjid,
parentGroupId,
groupLevel,
error: insertError.message,
stack: insertError.stack,
code: insertError.code,
detail: insertError.detail,
});
throw insertError;
}
// hierarchy_path 업데이트
let hierarchyPath = `/${newGroupId}/`;
if (parentGroupId) {
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
}
}
await client.query(
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
[hierarchyPath, newGroupId]
);
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[newGroupId, menuObjid]
);
menuToGroupMap.set(menuObjid, newGroupId);
result.created++;
result.details.push({
action: 'created',
sourceName: menuName,
sourceId: menuObjid,
targetId: newGroupId,
});
}
}
await client.query('COMMIT');
logger.info("메뉴 → 화면관리 동기화 완료", {
companyCode,
created: result.created,
linked: result.linked,
skipped: result.skipped
});
return result;
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("메뉴 → 화면관리 동기화 실패", {
companyCode,
error: error.message,
stack: error.stack,
code: error.code,
detail: error.detail,
});
result.success = false;
result.errors.push(error.message);
return result;
} finally {
client.release();
}
}
// ============================================================
// 동기화 상태 조회
// ============================================================
/**
*
*
* -
* -
* -
*/
export async function getSyncStatus(companyCode: string): Promise<{
screenGroups: { total: number; linked: number; unlinked: number };
menuItems: { total: number; linked: number; unlinked: number };
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
}> {
// screen_groups 상태
const sgQuery = `
SELECT
COUNT(*) as total,
COUNT(menu_objid) as linked
FROM screen_groups
WHERE company_code = $1
`;
const sgResult = await pool.query(sgQuery, [companyCode]);
// menu_info 상태 (사용자 메뉴만, 루트 제외)
const menuQuery = `
SELECT
COUNT(*) as total,
COUNT(screen_group_id) as linked
FROM menu_info
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
`;
const menuResult = await pool.query(menuQuery, [companyCode]);
// 이름이 같은 잠재적 매칭 후보 조회
const matchQuery = `
SELECT
m.menu_name_kor as menu_name,
sg.group_name
FROM menu_info m
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
WHERE m.company_code = $1
AND sg.company_code = $1
AND m.menu_type = 1
AND m.screen_group_id IS NULL
AND sg.menu_objid IS NULL
LIMIT 10
`;
const matchResult = await pool.query(matchQuery, [companyCode]);
const sgTotal = parseInt(sgResult.rows[0].total);
const sgLinked = parseInt(sgResult.rows[0].linked);
const menuTotal = parseInt(menuResult.rows[0].total);
const menuLinked = parseInt(menuResult.rows[0].linked);
return {
screenGroups: {
total: sgTotal,
linked: sgLinked,
unlinked: sgTotal - sgLinked,
},
menuItems: {
total: menuTotal,
linked: menuLinked,
unlinked: menuTotal - menuLinked,
},
potentialMatches: matchResult.rows.map((row: any) => ({
menuName: row.menu_name,
groupName: row.group_name,
similarity: 'exact',
})),
};
}
// ============================================================
// 전체 동기화 (모든 회사)
// ============================================================
interface AllCompaniesSyncResult {
success: boolean;
totalCompanies: number;
successCount: number;
failedCount: number;
results: Array<{
companyCode: string;
companyName: string;
direction: 'screens-to-menus' | 'menus-to-screens';
created: number;
linked: number;
skipped: number;
success: boolean;
error?: string;
}>;
}
/**
*
*
* :
* 1.
* 2.
* -
* -
* 3.
*/
export async function syncAllCompanies(
userId: string
): Promise<AllCompaniesSyncResult> {
const result: AllCompaniesSyncResult = {
success: true,
totalCompanies: 0,
successCount: 0,
failedCount: 0,
results: [],
};
try {
logger.info("전체 동기화 시작", { userId });
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
const companiesQuery = `
SELECT company_code, company_name
FROM company_mng
WHERE company_code != '*'
ORDER BY company_name
`;
const companiesResult = await pool.query(companiesQuery);
result.totalCompanies = companiesResult.rows.length;
// 각 회사별로 양방향 동기화
for (const company of companiesResult.rows) {
const companyCode = company.company_code;
const companyName = company.company_name;
try {
// 1. 화면관리 → 메뉴 동기화
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
result.results.push({
companyCode,
companyName,
direction: 'screens-to-menus',
created: screensToMenusResult.created,
linked: screensToMenusResult.linked,
skipped: screensToMenusResult.skipped,
success: screensToMenusResult.success,
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
});
// 2. 메뉴 → 화면관리 동기화
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
result.results.push({
companyCode,
companyName,
direction: 'menus-to-screens',
created: menusToScreensResult.created,
linked: menusToScreensResult.linked,
skipped: menusToScreensResult.skipped,
success: menusToScreensResult.success,
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
});
if (screensToMenusResult.success && menusToScreensResult.success) {
result.successCount++;
} else {
result.failedCount++;
}
} catch (error: any) {
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
result.results.push({
companyCode,
companyName,
direction: 'screens-to-menus',
created: 0,
linked: 0,
skipped: 0,
success: false,
error: error.message,
});
result.failedCount++;
}
}
logger.info("전체 동기화 완료", {
totalCompanies: result.totalCompanies,
successCount: result.successCount,
failedCount: result.failedCount,
});
return result;
} catch (error: any) {
logger.error("전체 동기화 실패", { error: error.message });
result.success = false;
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
const insertedData = { ...data };
console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
fields.push(mapping.targetField);
const value =
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
// 🔥 채번 규칙 서비스 동적 import
const { numberingRuleService } = await import("./numberingRuleService");
for (const mapping of fieldMappings) {
fields.push(mapping.targetField);
let value: any;
// 🔥 값 생성 유형에 따른 처리
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
// 자동 생성 (채번 규칙)
const companyCode = context.buttonContext?.companyCode || "*";
try {
value = await numberingRuleService.allocateCode(
mapping.numberingRuleId,
companyCode
);
console.log(
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
);
} catch (error: any) {
logger.error(`채번 규칙 적용 실패: ${error.message}`);
console.error(
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
);
throw new Error(
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
);
}
} else if (valueType === "static" || mapping.staticValue !== undefined) {
// 고정값
value = mapping.staticValue;
console.log(
` 📌 고정값: ${mapping.targetField} = ${value}`
);
} else {
// 소스 필드
value = data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
}
values.push(value);
// 🔥 삽입된 값을 데이터에 반영
insertedData[mapping.targetField] = value;
});
}
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
const hasWriterMapping = fieldMappings.some(
@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
}
});
// 🔑 Primary Key 자동 추가 (context-data 모드)
console.log("🔑 context-data 모드: Primary Key 자동 추가");
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
// 🔑 Primary Key 자동 추가 여부 결정:
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
let finalWhereConditions: any[];
if (whereConditions && whereConditions.length > 0) {
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
finalWhereConditions = whereConditions;
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause(
enhancedWhereConditions,
finalWhereConditions,
data,
paramIndex
);
@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
return deletedDataArray;
}
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
// 🆕 context-data 모드: 개별 삭제
console.log("🎯 context-data 모드: 개별 삭제 시작");
for (const data of dataArray) {
console.log("🔍 WHERE 조건 처리 중...");
// 🔑 Primary Key 자동 추가 (context-data 모드)
console.log("🔑 context-data 모드: Primary Key 자동 추가");
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
// 🔑 Primary Key 자동 추가 여부 결정:
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
let finalWhereConditions: any[];
if (whereConditions && whereConditions.length > 0) {
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
finalWhereConditions = whereConditions;
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause(
enhancedWhereConditions,
finalWhereConditions,
data,
1
);
@ -2865,10 +2916,11 @@ export class NodeFlowExecutionService {
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
logger.info(
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
);
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
return operator === "NOT_EXISTS_IN";
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
return false;
}
try {
@ -4394,6 +4446,8 @@ export class NodeFlowExecutionService {
/**
*
* : (leftOperand operator rightOperand) additionalOperations
* : (width * height) / 1000000 * qty
*/
private static evaluateArithmetic(
arithmetic: any,
@ -4420,27 +4474,67 @@ export class NodeFlowExecutionService {
const leftNum = Number(left) || 0;
const rightNum = Number(right) || 0;
switch (arithmetic.operator) {
// 기본 연산 수행
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
if (result === null) {
return null;
}
// 추가 연산 처리 (다중 연산 지원)
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
for (const addOp of arithmetic.additionalOperations) {
const operandValue = this.getOperandValue(
addOp.operand,
sourceRow,
targetRow,
resultValues
);
const operandNum = Number(operandValue) || 0;
result = this.applyOperator(result, addOp.operator, operandNum);
if (result === null) {
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
return null;
}
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
}
}
return result;
}
/**
*
*/
private static applyOperator(
left: number,
operator: string,
right: number
): number | null {
switch (operator) {
case "+":
return leftNum + rightNum;
return left + right;
case "-":
return leftNum - rightNum;
return left - right;
case "*":
return leftNum * rightNum;
return left * right;
case "/":
if (rightNum === 0) {
if (right === 0) {
logger.warn(`⚠️ 0으로 나누기 시도`);
return null;
}
return leftNum / rightNum;
return left / right;
case "%":
if (rightNum === 0) {
if (right === 0) {
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
return null;
}
return leftNum % rightNum;
return left % right;
default:
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
throw new Error(`지원하지 않는 연산자: ${operator}`);
}
}

View File

@ -2597,10 +2597,10 @@ export class ScreenManagementService {
// 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준)
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
const existingScreens = await client.query<any>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[copyData.screenCode, targetCompanyCode]
);

View File

@ -187,71 +187,68 @@ class TableCategoryValueService {
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
}
// 2. 카테고리 값 조회 (형제 메뉴 포함)
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
let query: string;
let params: any[];
const baseSelect = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
params = [tableName, columnName, siblingObjids];
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND menu_objid = $3`;
params = [tableName, columnName, menuObjid];
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
} else {
// menuObjid 없으면 모든 값 조회 (중복 가능)
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
}
} else {
// 일반 회사: 자신의 카테고리 값만 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
// 일반 회사: 자신의 회사 + menuObjid로 필터링
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
params = [tableName, columnName, companyCode, siblingObjids];
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
params = [tableName, columnName, companyCode, menuObjid];
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
} else {
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
query = baseSelect + ` AND company_code = $3`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
}
}
if (!includeInactive) {

View File

@ -1323,17 +1323,24 @@ export class TableManagementService {
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + (idx * 4);
const paramBase = paramIndex + idx * 4;
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
values.push(
safeValue,
`${safeValue},%`,
`%,${safeValue}`,
`%,${safeValue},%`
);
});
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
logger.info(
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
@ -1775,18 +1782,26 @@ export class TableManagementService {
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn;
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
if (
!displayColumn ||
displayColumn === "none" ||
displayColumn === ""
) {
displayColumn = await this.findDisplayColumnForTable(
referenceTable,
referenceColumn
);
logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
);
}
// 참조 테이블의 표시 컬럼으로 검색
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
return {
whereClause: `EXISTS (
SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = ${columnName}
WHERE ref.${referenceColumn} = main.${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex}
)`,
values: [`%${value}%`],
@ -2150,14 +2165,14 @@ export class TableManagementService {
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
const countResult = await query<any>(countQuery, searchValues);
const total = parseInt(countResult[0].count);
// 데이터 조회
// 데이터 조회 (main 별칭 추가)
const dataQuery = `
SELECT * FROM ${safeTableName}
SELECT main.* FROM ${safeTableName} main
${whereClause}
${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@ -2506,7 +2521,9 @@ export class TableManagementService {
});
if (skippedColumns.length > 0) {
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
logger.info(
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
);
}
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
@ -2711,6 +2728,12 @@ export class TableManagementService {
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
}
): Promise<EntityJoinResponse> {
const startTime = Date.now();
@ -2761,33 +2784,74 @@ export class TableManagementService {
);
for (const additionalColumn of options.additionalJoinColumns) {
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
const baseJoinConfig = joinConfigs.find(
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
let baseJoinConfig = joinConfigs.find(
(config) => config.sourceColumn === additionalColumn.sourceColumn
);
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
baseJoinConfig = joinConfigs.find(
(config) =>
config.referenceTable ===
(additionalColumn as any).referenceTable
);
if (baseJoinConfig) {
logger.info(
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`
);
}
}
if (baseJoinConfig) {
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
// joinAlias에서 실제 컬럼명 추출
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
let actualColumnName: string;
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${frontendSourceColumn}_`,
""
);
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
// 실제 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${sourceColumn}_`,
""
);
} else {
// 어느 것도 아니면 원본 사용
actualColumnName = originalJoinAlias;
}
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
logger.info(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn,
joinAlias,
frontendSourceColumn,
originalJoinAlias,
correctedJoinAlias,
actualColumnName,
referenceTable: additionalColumn.sourceTable,
referenceTable: (additionalColumn as any).referenceTable,
});
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin =
additionalColumn.joinAlias ===
`${baseJoinConfig.sourceColumn}_name`;
correctedJoinAlias === `${sourceColumn}_name`;
if (isBasicEntityJoin) {
logger.info(
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
);
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
@ -2795,14 +2859,14 @@ export class TableManagementService {
// 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자
};
@ -3162,8 +3226,10 @@ export class TableManagementService {
}
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
const allEntityColumns = [
...joinConfigs.map((config) => config.aliasColumn),
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
...joinConfigs.flatMap((config) => {
const additionalColumns = [];
@ -3569,8 +3635,10 @@ export class TableManagementService {
});
// main. 접두사 추가 (조인 쿼리용)
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
condition = condition.replace(
new RegExp(`\\b${columnName}\\b`, "g"),
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
`main.${columnName}`
);
conditions.push(condition);
@ -3775,6 +3843,9 @@ export class TableManagementService {
"customer_mng",
"item_info",
"dept_info",
"sales_order_mng", // 🔧 수주관리 테이블 추가
"sales_order_detail", // 🔧 수주상세 테이블 추가
"partner_info", // 🔧 거래처 테이블 추가
// 필요시 추가
];
@ -4693,15 +4764,19 @@ export class TableManagementService {
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>> {
): Promise<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
> {
try {
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
logger.info(
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
);
const relations: Array<{
leftColumn: string;
@ -4769,12 +4844,17 @@ export class TableManagementService {
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
logger.info(
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
);
});
return relations;
} catch (error) {
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
logger.error(
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
error
);
return [];
}
}

View File

@ -17,12 +17,30 @@ export interface LangKey {
langKey: string;
description?: string;
isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date;
createdBy?: string;
updatedDate?: Date;
updatedBy?: string;
}
// 카테고리 인터페이스
export interface LangCategory {
categoryId: number;
categoryCode: string;
categoryName: string;
parentId?: number | null;
level: number;
keyPrefix: string;
description?: string;
sortOrder: number;
isActive: string;
children?: LangCategory[];
}
export interface LangText {
textId?: number;
keyId: number;
@ -63,10 +81,38 @@ export interface CreateLangKeyRequest {
langKey: string;
description?: string;
isActive?: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdBy?: string;
updatedBy?: string;
}
// 자동 키 생성 요청
export interface GenerateKeyRequest {
companyCode: string;
categoryId: number;
keyMeaning: string;
usageNote?: string;
texts: Array<{
langCode: string;
langText: string;
}>;
createdBy?: string;
}
// 오버라이드 키 생성 요청
export interface CreateOverrideKeyRequest {
companyCode: string;
baseKeyId: number;
texts: Array<{
langCode: string;
langText: string;
}>;
createdBy?: string;
}
export interface UpdateLangKeyRequest {
companyCode?: string;
menuName?: string;
@ -90,6 +136,8 @@ export interface GetLangKeysParams {
menuCode?: string;
keyType?: string;
searchText?: string;
categoryId?: number;
includeOverrides?: boolean;
page?: number;
limit?: number;
}

View File

@ -588,3 +588,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -0,0 +1,597 @@
# 다국어 관리 시스템 개선 계획서
## 1. 개요
### 1.1 현재 시스템 분석
현재 ERP 시스템의 다국어 관리 시스템은 기본적인 기능은 갖추고 있으나 다음과 같은 한계점이 있습니다.
| 항목 | 현재 상태 | 문제점 |
|------|----------|--------|
| 회사별 다국어 | `company_code` 컬럼 존재하나 `*`(공통)만 사용 | 회사별 커스텀 번역 불가 |
| 언어 키 입력 | 수동 입력 (`button.add` 등) | 명명 규칙 불일치, 오타, 중복 위험 |
| 카테고리 분류 | 없음 (`menu_name` 텍스트만 존재) | 체계적 분류/검색 불가 |
| 권한 관리 | 없음 | 모든 사용자가 모든 키 수정 가능 |
| 조회 우선순위 | 없음 | 회사별 오버라이드 불가 |
### 1.2 개선 목표
1. **회사별 다국어 오버라이드 시스템**: 공통 키를 기본으로 사용하되, 회사별 커스텀 번역 지원
2. **권한 기반 접근 제어**: 공통 키는 최고 관리자만, 회사 키는 해당 회사만 수정
3. **카테고리 기반 분류**: 2단계 계층 구조로 체계적 분류
4. **자동 키 생성**: 카테고리 선택 + 의미 입력으로 규칙화된 키 자동 생성
5. **실시간 중복 체크**: 키 생성 시 중복 여부 즉시 확인
---
## 2. 데이터베이스 스키마 설계
### 2.1 신규 테이블: multi_lang_category (카테고리 마스터)
```sql
CREATE TABLE multi_lang_category (
category_id SERIAL PRIMARY KEY,
category_code VARCHAR(50) NOT NULL, -- BUTTON, FORM, MESSAGE 등
category_name VARCHAR(100) NOT NULL, -- 버튼, 폼, 메시지 등
parent_id INT4 REFERENCES multi_lang_category(category_id),
level INT4 DEFAULT 1, -- 1=대분류, 2=세부분류
key_prefix VARCHAR(50) NOT NULL, -- 키 생성용 prefix
description TEXT,
sort_order INT4 DEFAULT 0,
is_active CHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
UNIQUE(category_code, COALESCE(parent_id, 0))
);
-- 인덱스
CREATE INDEX idx_lang_category_parent ON multi_lang_category(parent_id);
CREATE INDEX idx_lang_category_level ON multi_lang_category(level);
```
### 2.2 기존 테이블 수정: multi_lang_key_master
```sql
-- 카테고리 연결 컬럼 추가
ALTER TABLE multi_lang_key_master
ADD COLUMN category_id INT4 REFERENCES multi_lang_category(category_id);
-- 키 의미 컬럼 추가 (자동 생성 시 사용자 입력값)
ALTER TABLE multi_lang_key_master
ADD COLUMN key_meaning VARCHAR(100);
-- 원본 키 참조 (오버라이드 시 원본 추적)
ALTER TABLE multi_lang_key_master
ADD COLUMN base_key_id INT4 REFERENCES multi_lang_key_master(key_id);
-- menu_name을 usage_note로 변경 (사용 위치 메모)
ALTER TABLE multi_lang_key_master
RENAME COLUMN menu_name TO usage_note;
-- 인덱스 추가
CREATE INDEX idx_lang_key_category ON multi_lang_key_master(category_id);
CREATE INDEX idx_lang_key_company_category ON multi_lang_key_master(company_code, category_id);
CREATE INDEX idx_lang_key_base ON multi_lang_key_master(base_key_id);
```
### 2.3 테이블 관계도
```
multi_lang_category (1) ◀────────┐
├── category_id (PK) │
├── category_code │
├── parent_id (자기참조) │
└── key_prefix │
multi_lang_key_master (N) ────────┘
├── key_id (PK)
├── company_code ('*' = 공통)
├── category_id (FK)
├── lang_key (자동 생성)
├── key_meaning (사용자 입력)
├── base_key_id (오버라이드 시 원본)
└── usage_note (사용 위치 메모)
multi_lang_text (N)
├── text_id (PK)
├── key_id (FK)
├── lang_code (FK → language_master)
└── lang_text
```
---
## 3. 카테고리 체계
### 3.1 대분류 (Level 1)
| category_code | category_name | key_prefix | 설명 |
|---------------|---------------|------------|------|
| COMMON | 공통 | common | 범용 텍스트 |
| BUTTON | 버튼 | button | 버튼 텍스트 |
| FORM | 폼 | form | 폼 라벨, 플레이스홀더 |
| TABLE | 테이블 | table | 테이블 헤더, 빈 상태 |
| MESSAGE | 메시지 | message | 알림, 경고, 성공 메시지 |
| MENU | 메뉴 | menu | 메뉴명, 네비게이션 |
| MODAL | 모달 | modal | 모달/다이얼로그 |
| VALIDATION | 검증 | validation | 유효성 검사 메시지 |
| STATUS | 상태 | status | 상태 표시 텍스트 |
| TOOLTIP | 툴팁 | tooltip | 툴팁, 도움말 |
### 3.2 세부분류 (Level 2)
#### BUTTON 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| ACTION | 액션 | action |
| NAVIGATION | 네비게이션 | nav |
| TOGGLE | 토글 | toggle |
#### FORM 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| LABEL | 라벨 | label |
| PLACEHOLDER | 플레이스홀더 | placeholder |
| HELPER | 도움말 | helper |
#### MESSAGE 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| SUCCESS | 성공 | success |
| ERROR | 에러 | error |
| WARNING | 경고 | warning |
| INFO | 안내 | info |
| CONFIRM | 확인 | confirm |
#### TABLE 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| HEADER | 헤더 | header |
| EMPTY | 빈 상태 | empty |
| PAGINATION | 페이지네이션 | pagination |
#### MENU 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| ADMIN | 관리자 | admin |
| USER | 사용자 | user |
#### MODAL 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| TITLE | 제목 | title |
| DESCRIPTION | 설명 | description |
### 3.3 키 자동 생성 규칙
**형식**: `{대분류_prefix}.{세부분류_prefix}.{key_meaning}`
**예시**:
| 대분류 | 세부분류 | 의미 입력 | 생성 키 |
|--------|----------|----------|---------|
| BUTTON | ACTION | save | `button.action.save` |
| BUTTON | ACTION | delete_selected | `button.action.delete_selected` |
| FORM | LABEL | user_name | `form.label.user_name` |
| FORM | PLACEHOLDER | search | `form.placeholder.search` |
| MESSAGE | SUCCESS | save_complete | `message.success.save_complete` |
| MESSAGE | ERROR | network_fail | `message.error.network_fail` |
| TABLE | HEADER | created_date | `table.header.created_date` |
| MENU | ADMIN | user_management | `menu.admin.user_management` |
---
## 4. 회사별 다국어 시스템
### 4.1 조회 우선순위
다국어 텍스트 조회 시 다음 우선순위를 적용합니다:
1. **회사 전용 키** (`company_code = 'COMPANY_A'`)
2. **공통 키** (`company_code = '*'`)
```sql
-- 조회 쿼리 예시
WITH ranked_keys AS (
SELECT
km.lang_key,
mt.lang_text,
km.company_code,
ROW_NUMBER() OVER (
PARTITION BY km.lang_key
ORDER BY CASE WHEN km.company_code = $1 THEN 1 ELSE 2 END
) as priority
FROM multi_lang_key_master km
JOIN multi_lang_text mt ON km.key_id = mt.key_id
WHERE km.lang_key = ANY($2)
AND mt.lang_code = $3
AND km.is_active = 'Y'
AND km.company_code IN ($1, '*')
)
SELECT lang_key, lang_text
FROM ranked_keys
WHERE priority = 1;
```
### 4.2 오버라이드 프로세스
1. 회사 관리자가 공통 키에서 "이 회사 전용으로 복사" 클릭
2. 시스템이 `base_key_id`에 원본 키를 참조하는 새 키 생성
3. 기존 번역 텍스트 복사
4. 회사 관리자가 번역 수정
5. 이후 해당 회사 사용자는 회사 전용 번역 사용
### 4.3 권한 매트릭스
| 작업 | 최고 관리자 (`*`) | 회사 관리자 | 일반 사용자 |
|------|------------------|-------------|-------------|
| 공통 키 조회 | O | O | O |
| 공통 키 생성 | O | X | X |
| 공통 키 수정 | O | X | X |
| 공통 키 삭제 | O | X | X |
| 회사 키 조회 | O | 자사만 | 자사만 |
| 회사 키 생성 (오버라이드) | O | O | X |
| 회사 키 수정 | O | 자사만 | X |
| 회사 키 삭제 | O | 자사만 | X |
| 카테고리 관리 | O | X | X |
---
## 5. API 설계
### 5.1 카테고리 API
| 엔드포인트 | 메서드 | 설명 | 권한 |
|-----------|--------|------|------|
| `/multilang/categories` | GET | 카테고리 목록 조회 | 인증 필요 |
| `/multilang/categories/tree` | GET | 계층 구조로 조회 | 인증 필요 |
| `/multilang/categories` | POST | 카테고리 생성 | 최고 관리자 |
| `/multilang/categories/:id` | PUT | 카테고리 수정 | 최고 관리자 |
| `/multilang/categories/:id` | DELETE | 카테고리 삭제 | 최고 관리자 |
### 5.2 다국어 키 API (개선)
| 엔드포인트 | 메서드 | 설명 | 권한 |
|-----------|--------|------|------|
| `/multilang/keys` | GET | 키 목록 조회 (카테고리/회사 필터) | 인증 필요 |
| `/multilang/keys` | POST | 키 생성 | 공통: 최고관리자, 회사: 회사관리자 |
| `/multilang/keys/:keyId` | PUT | 키 수정 | 공통: 최고관리자, 회사: 해당회사 |
| `/multilang/keys/:keyId` | DELETE | 키 삭제 | 공통: 최고관리자, 회사: 해당회사 |
| `/multilang/keys/:keyId/override` | POST | 공통 키를 회사 전용으로 복사 | 회사 관리자 |
| `/multilang/keys/check` | GET | 키 중복 체크 | 인증 필요 |
| `/multilang/keys/generate-preview` | POST | 키 자동 생성 미리보기 | 인증 필요 |
### 5.3 API 요청/응답 예시
#### 키 생성 요청
```json
POST /multilang/keys
{
"categoryId": 11, // 세부분류 ID (BUTTON > ACTION)
"keyMeaning": "save_changes",
"description": "변경사항 저장 버튼",
"usageNote": "사용자 관리, 설정 화면",
"texts": [
{ "langCode": "KR", "langText": "저장하기" },
{ "langCode": "US", "langText": "Save Changes" },
{ "langCode": "JP", "langText": "保存する" }
]
}
```
#### 키 생성 응답
```json
{
"success": true,
"message": "다국어 키가 생성되었습니다.",
"data": {
"keyId": 175,
"langKey": "button.action.save_changes",
"companyCode": "*",
"categoryId": 11
}
}
```
#### 오버라이드 요청
```json
POST /multilang/keys/123/override
{
"texts": [
{ "langCode": "KR", "langText": "등록하기" },
{ "langCode": "US", "langText": "Register" }
]
}
```
---
## 6. 프론트엔드 UI 설계
### 6.1 다국어 관리 페이지 리뉴얼
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 다국어 관리 │
│ 다국어 키와 번역 텍스트를 관리합니다 │
├─────────────────────────────────────────────────────────────────────────┤
│ [언어 관리] [다국어 키 관리] [카테고리 관리] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────┐ ┌───────────────────────────────────────────────┤
│ │ 카테고리 필터 │ │ │
│ │ │ │ 검색: [________________] 회사: [전체 ▼] │
│ │ ▼ 버튼 (45) │ │ [초기화] [+ 키 등록] │
│ │ ├ 액션 (30) │ │───────────────────────────────────────────────│
│ │ ├ 네비게이션 (10)│ │ ☐ │ 키 │ 카테고리 │ 회사 │ 상태 │
│ │ └ 토글 (5) │ │───────────────────────────────────────────────│
│ │ ▼ 폼 (60) │ │ ☐ │ button.action.save │ 버튼>액션 │ 공통 │ 활성 │
│ │ ├ 라벨 (35) │ │ ☐ │ button.action.save │ 버튼>액션 │ A사 │ 활성 │
│ │ ├ 플레이스홀더(15)│ │ ☐ │ button.action.delete │ 버튼>액션 │ 공통 │ 활성 │
│ │ └ 도움말 (10) │ │ ☐ │ form.label.user_name │ 폼>라벨 │ 공통 │ 활성 │
│ │ ▶ 메시지 (40) │ │───────────────────────────────────────────────│
│ │ ▶ 테이블 (20) │ │ 페이지: [1] [2] [3] ... [10] │
│ │ ▶ 메뉴 (9) │ │ │
│ └────────────────────┘ └───────────────────────────────────────────────┤
└─────────────────────────────────────────────────────────────────────────┘
```
### 6.2 키 등록 모달
```
┌─────────────────────────────────────────────────────────────────┐
│ 다국어 키 등록 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ① 카테고리 선택 │
│ ┌───────────────────────────────────────────────────────────────┤
│ │ 대분류 * │ 세부 분류 *
│ │ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
│ │ │ 공통 │ │ │ (대분류 먼저 선택) │ │
│ │ │ ● 버튼 │ │ │ ● 액션 │ │
│ │ │ 폼 │ │ │ 네비게이션 │ │
│ │ │ 테이블 │ │ │ 토글 │ │
│ │ │ 메시지 │ │ │ │ │
│ │ └─────────────────────────┘ │ └─────────────────────────┘ │
│ └───────────────────────────────────────────────────────────────┤
│ │
│ ② 키 정보 입력 │
│ ┌───────────────────────────────────────────────────────────────┤
│ │ 키 의미 (영문) * │
│ │ [ save_changes ] │
│ │ 영문 소문자, 밑줄(_) 사용. 예: save, add_new, delete_all │
│ │ │
│ │ ───────────────────────────────────────────────────────── │
│ │ 자동 생성 키: │
│ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ button.action.save_changes │ │
│ │ └─────────────────────────────────────────────────────────┘ │
│ │ ✓ 사용 가능한 키입니다 │
│ └───────────────────────────────────────────────────────────────┤
│ │
│ ③ 설명 및 번역 │
│ ┌───────────────────────────────────────────────────────────────┤
│ │ 설명 (선택) │
│ │ [ 변경사항을 저장하는 버튼 ] │
│ │ │
│ │ 사용 위치 메모 (선택) │
│ │ [ 사용자 관리, 설정 화면 ] │
│ │ │
│ │ ───────────────────────────────────────────────────────── │
│ │ 번역 텍스트 │
│ │ │
│ │ 한국어 (KR) * [ 저장하기 ] │
│ │ English (US) [ Save Changes ] │
│ │ 日本語 (JP) [ 保存する ] │
│ └───────────────────────────────────────────────────────────────┤
│ │
├─────────────────────────────────────────────────────────────────┤
│ [취소] [등록] │
└─────────────────────────────────────────────────────────────────┘
```
### 6.3 공통 키 편집 모달 (회사 관리자용)
```
┌─────────────────────────────────────────────────────────────────┐
│ 다국어 키 상세 │
│ button.action.save (공통) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 카테고리: 버튼 > 액션 │
│ 설명: 저장 버튼 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ 번역 텍스트 (읽기 전용) │
│ │
│ 한국어 (KR) 저장 │
│ English (US) Save │
│ 日本語 (JP) 保存 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 공통 키는 수정할 수 없습니다. │
│ 이 회사만의 번역이 필요하시면 아래 버튼을 클릭하세요. │
│ │
│ [이 회사 전용으로 복사] │
├─────────────────────────────────────────────────────────────────┤
│ [닫기] │
└─────────────────────────────────────────────────────────────────┘
```
### 6.4 회사 전용 키 생성 모달 (오버라이드)
```
┌─────────────────────────────────────────────────────────────────┐
│ 회사 전용 키 생성 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 원본 키: button.action.save (공통) │
│ │
│ 원본 번역: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 한국어: 저장 │ │
│ │ English: Save │ │
│ │ 日本語: 保存 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 이 회사 전용 번역 텍스트: │
│ │
│ 한국어 (KR) * [ 등록하기 ] │
│ English (US) [ Register ] │
│ 日本語 (JP) [ 登録 ] │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 회사 전용 키를 생성하면 공통 키 대신 사용됩니다. │
│ 원본 키가 변경되어도 회사 전용 키는 영향받지 않습니다. │
├─────────────────────────────────────────────────────────────────┤
│ [취소] [생성] │
└─────────────────────────────────────────────────────────────────┘
```
---
## 7. 구현 계획
### 7.1 Phase 1: 데이터베이스 마이그레이션
**예상 소요 시간: 2시간**
1. 카테고리 테이블 생성
2. 기본 카테고리 데이터 삽입 (대분류 10개, 세부분류 약 20개)
3. multi_lang_key_master 스키마 변경
4. 기존 174개 키 카테고리 자동 분류 (패턴 매칭)
**마이그레이션 파일**: `db/migrations/075_multilang_category_system.sql`
### 7.2 Phase 2: 백엔드 API 개발
**예상 소요 시간: 4시간**
1. 카테고리 CRUD API
2. 키 조회 로직 수정 (우선순위 적용)
3. 권한 검사 미들웨어
4. 오버라이드 API
5. 키 중복 체크 API
6. 키 자동 생성 미리보기 API
**관련 파일**:
- `backend-node/src/controllers/multilangController.ts`
- `backend-node/src/services/multilangService.ts`
- `backend-node/src/routes/multilangRoutes.ts`
### 7.3 Phase 3: 프론트엔드 UI 개발
**예상 소요 시간: 6시간**
1. 카테고리 트리 컴포넌트
2. 키 등록 모달 리뉴얼 (단계별 입력)
3. 키 편집 모달 (권한별 UI 분기)
4. 오버라이드 모달
5. 카테고리 관리 탭 추가
**관련 파일**:
- `frontend/app/(main)/admin/systemMng/i18nList/page.tsx`
- `frontend/components/multilang/LangKeyModal.tsx` (리뉴얼)
- `frontend/components/multilang/CategoryTree.tsx` (신규)
- `frontend/components/multilang/OverrideModal.tsx` (신규)
### 7.4 Phase 4: 테스트 및 마이그레이션
**예상 소요 시간: 2시간**
1. API 테스트
2. UI 테스트
3. 기존 데이터 마이그레이션 검증
4. 권한 테스트 (최고 관리자, 회사 관리자)
---
## 8. 상세 구현 일정
| 단계 | 작업 | 예상 시간 | 의존성 |
|------|------|----------|--------|
| 1.1 | 마이그레이션 SQL 작성 | 30분 | - |
| 1.2 | 카테고리 기본 데이터 삽입 | 30분 | 1.1 |
| 1.3 | 기존 키 카테고리 자동 분류 | 30분 | 1.2 |
| 1.4 | 스키마 변경 검증 | 30분 | 1.3 |
| 2.1 | 카테고리 API 개발 | 1시간 | 1.4 |
| 2.2 | 키 조회 로직 수정 (우선순위) | 1시간 | 2.1 |
| 2.3 | 권한 검사 로직 추가 | 30분 | 2.2 |
| 2.4 | 오버라이드 API 개발 | 1시간 | 2.3 |
| 2.5 | 키 생성 API 개선 (자동 생성) | 30분 | 2.4 |
| 3.1 | 카테고리 트리 컴포넌트 | 1시간 | 2.5 |
| 3.2 | 키 등록 모달 리뉴얼 | 2시간 | 3.1 |
| 3.3 | 키 편집/상세 모달 | 1시간 | 3.2 |
| 3.4 | 오버라이드 모달 | 1시간 | 3.3 |
| 3.5 | 카테고리 관리 탭 | 1시간 | 3.4 |
| 4.1 | 통합 테스트 | 1시간 | 3.5 |
| 4.2 | 버그 수정 및 마무리 | 1시간 | 4.1 |
**총 예상 시간: 약 14시간**
---
## 9. 기대 효과
### 9.1 개선 전후 비교
| 항목 | 현재 | 개선 후 |
|------|------|---------|
| 키 명명 규칙 | 불규칙 (수동 입력) | 규칙화 (자동 생성) |
| 카테고리 분류 | 없음 | 2단계 계층 구조 |
| 회사별 다국어 | 미활용 | 오버라이드 지원 |
| 조회 우선순위 | 없음 | 회사 전용 > 공통 |
| 권한 관리 | 없음 | 역할별 접근 제어 |
| 중복 체크 | 저장 시에만 | 실시간 검증 |
| 검색/필터 | 키 이름만 | 카테고리 + 회사 + 키 |
### 9.2 사용자 경험 개선
1. **일관된 키 명명**: 자동 생성으로 규칙 준수
2. **빠른 검색**: 카테고리 기반 필터링
3. **회사별 커스터마이징**: 브랜드에 맞는 번역 사용
4. **안전한 수정**: 권한 기반 보호
### 9.3 유지보수 개선
1. **체계적 분류**: 어떤 텍스트가 어디에 사용되는지 명확
2. **변경 영향 파악**: 오버라이드 추적으로 영향 범위 확인
3. **권한 분리**: 공통 키 보호, 회사별 자율성 보장
---
## 10. 참고 자료
### 10.1 관련 파일
| 파일 | 설명 |
|------|------|
| `frontend/hooks/useMultiLang.ts` | 다국어 훅 |
| `frontend/lib/utils/multilang.ts` | 다국어 유틸리티 |
| `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` | 다국어 관리 페이지 |
| `backend-node/src/controllers/multilangController.ts` | API 컨트롤러 |
| `backend-node/src/services/multilangService.ts` | 비즈니스 로직 |
| `docs/다국어_시스템_가이드.md` | 기존 시스템 가이드 |
### 10.2 데이터베이스 테이블
| 테이블 | 설명 |
|--------|------|
| `language_master` | 언어 마스터 (KR, US, JP) |
| `multi_lang_key_master` | 다국어 키 마스터 |
| `multi_lang_text` | 다국어 번역 텍스트 |
| `multi_lang_category` | 다국어 카테고리 (신규) |
---
## 11. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------|
| 1.0 | 2026-01-13 | AI | 최초 작성 |

View File

@ -361,3 +361,4 @@

View File

@ -347,3 +347,4 @@ const getComponentValue = (componentId: string) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,68 +1,127 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template";
type ViewMode = "tree" | "table";
export default function ScreenManagementPage() {
const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
try {
setLoading(true);
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" });
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
if (result.data && result.data.length > 0) {
setScreens(result.data);
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadScreens();
}, [loadScreens]);
// 화면 목록 새로고침 이벤트 리스너
useEffect(() => {
const handleScreenListRefresh = () => {
console.log("🔄 화면 목록 새로고침 이벤트 수신");
loadScreens();
};
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
return () => {
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
};
}, [loadScreens]);
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
useEffect(() => {
const openDesignerId = searchParams.get("openDesigner");
if (openDesignerId && screens.length > 0) {
const screenId = parseInt(openDesignerId, 10);
const targetScreen = screens.find((s) => s.screenId === screenId);
if (targetScreen) {
setSelectedScreen(targetScreen);
setCurrentStep("design");
setStepHistory(["list", "design"]);
}
}
}, [searchParams, screens]);
// 화면 설계 모드일 때는 전체 화면 사용
const isDesignMode = currentStep === "design";
// 단계별 제목과 설명
const stepConfig = {
list: {
title: "화면 목록 관리",
description: "생성된 화면들을 확인하고 관리하세요",
},
design: {
title: "화면 설계",
description: "드래그앤드롭으로 화면을 설계하세요",
},
template: {
title: "템플릿 관리",
description: "화면 템플릿을 관리하고 재사용하세요",
},
};
// 다음 단계로 이동
const goToNextStep = (nextStep: Step) => {
setStepHistory((prev) => [...prev, nextStep]);
setCurrentStep(nextStep);
};
// 이전 단계로 이동
const goToPreviousStep = () => {
if (stepHistory.length > 1) {
const newHistory = stepHistory.slice(0, -1);
const previousStep = newHistory[newHistory.length - 1];
setStepHistory(newHistory);
setCurrentStep(previousStep);
}
};
// 특정 단계로 이동
const goToStep = (step: Step) => {
setCurrentStep(step);
// 해당 단계까지의 히스토리만 유지
const stepIndex = stepHistory.findIndex((s) => s === step);
if (stepIndex !== -1) {
setStepHistory(stepHistory.slice(0, stepIndex + 1));
}
};
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setSelectedGroup(null); // 그룹 선택 해제
};
// 화면 디자인 핸들러
const handleDesignScreen = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
goToNextStep("design");
};
// 검색어로 필터링된 화면
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
// 단일 키워드면 해당 키워드로 화면 필터링
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
const filteredScreens = searchKeywords.length > 1
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
: screens.filter((screen) =>
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
);
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
if (isDesignMode) {
return (
<div className="fixed inset-0 z-50 bg-background">
@ -72,59 +131,119 @@ export default function ScreenManagementPage() {
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-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"> 릿 </p>
</div>
{/* 단계별 내용 */}
<div className="flex-1">
{/* 화면 목록 단계 */}
{currentStep === "list" && (
<ScreenList
onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
onDesignScreen={(screen) => {
setSelectedScreen(screen);
goToNextStep("design");
}}
/>
)}
{/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button
variant="outline"
onClick={goToPreviousStep}
className="h-10 gap-2 text-sm font-medium"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
onClick={() => goToStep("list")}
className="h-10 gap-2 text-sm font-medium"
>
</Button>
</div>
</div>
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
)}
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
<div className="flex items-center gap-2">
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{viewMode === "tree" ? (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null); // 화면 선택 해제
setFocusedScreenIdInGroup(null); // 포커스 초기화
}}
onScreenSelectInGroup={(group, screenId) => {
// 그룹 내 화면 클릭 시
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
initialFocusedScreenId={focusedScreenIdInGroup}
/>
</div>
</div>
) : (
// 테이블 뷰 (기존 ScreenList 사용)
<div className="flex-1 overflow-auto p-6">
<ScreenList
onScreenSelect={handleScreenSelect}
selectedScreen={selectedScreen}
onDesignScreen={handleDesignScreen}
/>
</div>
)}
{/* 화면 생성 모달 */}
<CreateScreenModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
setIsCreateOpen(false);
loadScreens();
}}
/>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -7,13 +7,19 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react";
import { DataTable } from "@/components/common/DataTable";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useAuth } from "@/hooks/useAuth";
import LangKeyModal from "@/components/admin/LangKeyModal";
import LanguageModal from "@/components/admin/LanguageModal";
import { CategoryTree } from "@/components/admin/multilang/CategoryTree";
import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal";
import { apiClient } from "@/lib/api/client";
import { LangCategory } from "@/lib/api/multilang";
interface Language {
langCode: string;
@ -29,6 +35,7 @@ interface LangKey {
langKey: string;
description: string;
isActive: string;
categoryId?: number;
}
interface LangText {
@ -59,6 +66,10 @@ export default function I18nPage() {
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
// 카테고리 관련 상태
const [selectedCategory, setSelectedCategory] = useState<LangCategory | null>(null);
const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
// 회사 목록 조회
@ -92,9 +103,14 @@ export default function I18nPage() {
};
// 다국어 키 목록 조회
const fetchLangKeys = async () => {
const fetchLangKeys = async (categoryId?: number | null) => {
try {
const response = await apiClient.get("/multilang/keys");
const params = new URLSearchParams();
if (categoryId) {
params.append("categoryId", categoryId.toString());
}
const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiClient.get(url);
const data = response.data;
if (data.success) {
setLangKeys(data.data);
@ -471,6 +487,13 @@ export default function I18nPage() {
initializeData();
}, []);
// 카테고리 변경 시 키 목록 다시 조회
useEffect(() => {
if (!loading) {
fetchLangKeys(selectedCategory?.categoryId);
}
}, [selectedCategory?.categoryId]);
const columns = [
{
id: "select",
@ -678,27 +701,70 @@ export default function I18nPage() {
{/* 다국어 키 관리 탭 */}
{activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
{/* 좌측: 언어 키 목록 (7/10) */}
<Card className="lg:col-span-7">
<CardHeader>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* 좌측: 카테고리 트리 (2/12) */}
<Card className="lg:col-span-2">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle> </CardTitle>
<CardTitle className="text-sm"></CardTitle>
</div>
</CardHeader>
<CardContent className="p-2">
<ScrollArea className="h-[500px]">
<CategoryTree
selectedCategoryId={selectedCategory?.categoryId || null}
onSelectCategory={(cat) => setSelectedCategory(cat)}
onDoubleClickCategory={(cat) => {
setSelectedCategory(cat);
setIsGenerateModalOpen(true);
}}
/>
</ScrollArea>
</CardContent>
</Card>
{/* 중앙: 언어 키 목록 (6/12) */}
<Card className="lg:col-span-6">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedCategory && (
<Badge variant="secondary" className="ml-2">
{selectedCategory.categoryName}
</Badge>
)}
</CardTitle>
<div className="flex space-x-2">
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
<Button
size="sm"
variant="destructive"
onClick={handleDeleteSelectedKeys}
disabled={selectedKeys.size === 0}
>
({selectedKeys.size})
</Button>
<Button onClick={handleAddKey}> </Button>
<Button size="sm" variant="outline" onClick={handleAddKey}>
</Button>
<Button
size="sm"
onClick={() => setIsGenerateModalOpen(true)}
disabled={!selectedCategory}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-0">
{/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div>
<Label htmlFor="company"></Label>
<Label htmlFor="company" className="text-xs"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
@ -713,22 +779,22 @@ export default function I18nPage() {
</div>
<div>
<Label htmlFor="search"></Label>
<Label htmlFor="search" className="text-xs"></Label>
<Input
placeholder="키명, 설명, 메뉴, 회사로 검색..."
placeholder="키명, 설명로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex items-end">
<div className="text-sm text-muted-foreground"> : {getFilteredLangKeys().length}</div>
<div className="text-xs text-muted-foreground">: {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<div className="mb-2 text-sm text-muted-foreground">: {getFilteredLangKeys().length}</div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}
@ -739,8 +805,8 @@ export default function I18nPage() {
</CardContent>
</Card>
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
<Card className="lg:col-span-3">
{/* 우측: 선택된 키의 다국어 관리 (4/12) */}
<Card className="lg:col-span-4">
<CardHeader>
<CardTitle>
{selectedKey ? (
@ -817,6 +883,18 @@ export default function I18nPage() {
onSave={handleSaveLanguage}
languageData={editingLanguage}
/>
{/* 키 자동 생성 모달 */}
<KeyGenerateModal
isOpen={isGenerateModalOpen}
onClose={() => setIsGenerateModalOpen(false)}
selectedCategory={selectedCategory}
companyCode={user?.companyCode || ""}
isSuperAdmin={user?.companyCode === "*"}
onSuccess={() => {
fetchLangKeys(selectedCategory?.categoryId);
}}
/>
</div>
</div>
</div>

View File

@ -33,8 +33,17 @@ function ScreenViewPage() {
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code");
// 프리뷰 모드 감지 (iframe에서 로드될 때)
const isPreviewMode = searchParams.get("preview") === "true";
// 🆕 현재 로그인한 사용자 정보
const { user, userName, companyCode } = useAuth();
const { user, userName, companyCode: authCompanyCode } = useAuth();
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
const companyCode = previewCompanyCode || authCompanyCode;
// 🆕 모바일 환경 감지
const { isMobile } = useResponsive();
@ -104,7 +113,7 @@ function ScreenViewPage() {
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,
@ -233,27 +242,40 @@ function ScreenViewPage() {
const designWidth = layout?.screenResolution?.width || 1200;
const designHeight = layout?.screenResolution?.height || 800;
// 컨테이너의 실제 크기
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
let containerWidth: number;
let containerHeight: number;
// 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8)
if (isPreviewMode) {
// iframe에서는 window 크기를 직접 사용
containerWidth = window.innerWidth;
containerHeight = window.innerHeight;
} else {
containerWidth = containerRef.current.offsetWidth;
containerHeight = containerRef.current.offsetHeight;
}
let newScale: number;
if (isPreviewMode) {
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
const scaleX = containerWidth / designWidth;
const scaleY = containerHeight / designHeight;
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
} else {
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X;
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
const newScale = availableWidth / designWidth;
newScale = availableWidth / designWidth;
}
// console.log("📐 스케일 계산:", {
// containerWidth,
// containerHeight,
// MARGIN_X,
// availableWidth,
// designWidth,
// designHeight,
// finalScale: newScale,
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
// isPreviewMode,
// });
setScale(newScale);
@ -272,7 +294,7 @@ function ScreenViewPage() {
return () => {
clearTimeout(timer);
};
}, [layout, isMobile]);
}, [layout, isMobile, isPreviewMode]);
if (loading) {
return (
@ -310,7 +332,7 @@ function ScreenViewPage() {
<ScreenPreviewProvider isPreviewMode={false}>
<ActiveTabProvider>
<TableOptionsProvider>
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
<div ref={containerRef} className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}>
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">

View File

@ -388,226 +388,18 @@ select {
border-spacing: 0 !important;
}
/* ===== POP (Production Operation Panel) Styles ===== */
/* POP 전용 다크 테마 변수 */
.pop-dark {
/* 배경 색상 */
--pop-bg-deepest: 8 12 21;
--pop-bg-deep: 10 15 28;
--pop-bg-primary: 13 19 35;
--pop-bg-secondary: 18 26 47;
--pop-bg-tertiary: 25 35 60;
--pop-bg-elevated: 32 45 75;
/* 네온 강조색 */
--pop-neon-cyan: 0 212 255;
--pop-neon-cyan-bright: 0 240 255;
--pop-neon-cyan-dim: 0 150 190;
--pop-neon-pink: 255 0 102;
--pop-neon-purple: 138 43 226;
/* 상태 색상 */
--pop-success: 0 255 136;
--pop-success-dim: 0 180 100;
--pop-warning: 255 170 0;
--pop-warning-dim: 200 130 0;
--pop-danger: 255 51 51;
--pop-danger-dim: 200 40 40;
/* 텍스트 색상 */
--pop-text-primary: 255 255 255;
--pop-text-secondary: 180 195 220;
--pop-text-muted: 100 120 150;
/* 테두리 색상 */
--pop-border: 40 55 85;
--pop-border-light: 55 75 110;
}
/* POP 전용 라이트 테마 변수 */
.pop-light {
--pop-bg-deepest: 245 247 250;
--pop-bg-deep: 240 243 248;
--pop-bg-primary: 250 251 253;
--pop-bg-secondary: 255 255 255;
--pop-bg-tertiary: 245 247 250;
--pop-bg-elevated: 235 238 245;
--pop-neon-cyan: 0 122 204;
--pop-neon-cyan-bright: 0 140 230;
--pop-neon-cyan-dim: 0 100 170;
--pop-neon-pink: 220 38 127;
--pop-neon-purple: 118 38 200;
--pop-success: 22 163 74;
--pop-success-dim: 21 128 61;
--pop-warning: 245 158 11;
--pop-warning-dim: 217 119 6;
--pop-danger: 220 38 38;
--pop-danger-dim: 185 28 28;
--pop-text-primary: 15 23 42;
--pop-text-secondary: 71 85 105;
--pop-text-muted: 148 163 184;
--pop-border: 226 232 240;
--pop-border-light: 203 213 225;
}
/* POP 배경 그리드 패턴 */
.pop-bg-pattern::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
.pop-light .pop-bg-pattern::before {
background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
}
/* POP 글로우 효과 */
.pop-glow-cyan {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
}
.pop-glow-cyan-strong {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
}
.pop-glow-success {
box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
}
.pop-glow-warning {
box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
}
.pop-glow-danger {
box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
}
/* POP 펄스 글로우 애니메이션 */
@keyframes pop-pulse-glow {
0%,
100% {
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
}
}
.pop-animate-pulse-glow {
animation: pop-pulse-glow 2s ease-in-out infinite;
}
/* POP 프로그레스 바 샤인 애니메이션 */
@keyframes pop-progress-shine {
/* ===== 저장 테이블 막대기 애니메이션 ===== */
@keyframes saveBarDrop {
0% {
transform: scaleY(0);
transform-origin: top;
opacity: 0;
transform: translateX(-20px);
}
50% {
100% {
transform: scaleY(1);
transform-origin: top;
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(20px);
}
}
.pop-progress-shine::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
animation: pop-progress-shine 1.5s ease-in-out infinite;
}
/* POP 스크롤바 스타일 */
.pop-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.pop-scrollbar::-webkit-scrollbar-track {
background: rgb(var(--pop-bg-secondary));
}
.pop-scrollbar::-webkit-scrollbar-thumb {
background: rgb(var(--pop-border-light));
border-radius: 9999px;
}
.pop-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(var(--pop-neon-cyan-dim));
}
/* POP 스크롤바 숨기기 */
.pop-hide-scrollbar::-webkit-scrollbar {
display: none;
}
.pop-hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ===== Marching Ants Animation (Excel Copy Border) ===== */
@keyframes marching-ants-h {
0% {
background-position: 0 0;
}
100% {
background-position: 16px 0;
}
}
@keyframes marching-ants-v {
0% {
background-position: 0 0;
}
100% {
background-position: 0 16px;
}
}
.animate-marching-ants-h {
background: repeating-linear-gradient(
90deg,
hsl(var(--primary)) 0,
hsl(var(--primary)) 4px,
transparent 4px,
transparent 8px
);
background-size: 16px 2px;
animation: marching-ants-h 0.4s linear infinite;
}
.animate-marching-ants-v {
background: repeating-linear-gradient(
180deg,
hsl(var(--primary)) 0,
hsl(var(--primary)) 4px,
transparent 4px,
transparent 8px
);
background-size: 2px 16px;
animation: marching-ants-v 0.4s linear infinite;
}
/* ===== End of Global Styles ===== */

View File

@ -0,0 +1,200 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react";
import { cn } from "@/lib/utils";
import { LangCategory, getCategories } from "@/lib/api/multilang";
interface CategoryTreeProps {
selectedCategoryId: number | null;
onSelectCategory: (category: LangCategory | null) => void;
onDoubleClickCategory?: (category: LangCategory) => void;
}
interface CategoryNodeProps {
category: LangCategory;
level: number;
selectedCategoryId: number | null;
onSelectCategory: (category: LangCategory) => void;
onDoubleClickCategory?: (category: LangCategory) => void;
}
function CategoryNode({
category,
level,
selectedCategoryId,
onSelectCategory,
onDoubleClickCategory,
}: CategoryNodeProps) {
// 기본값: 접힌 상태로 시작
const [isExpanded, setIsExpanded] = useState(false);
const hasChildren = category.children && category.children.length > 0;
const isSelected = selectedCategoryId === category.categoryId;
return (
<div>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelectCategory(category)}
onDoubleClick={() => onDoubleClickCategory?.(category)}
>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="w-4" />
)}
{/* 폴더/태그 아이콘 */}
{hasChildren || level === 0 ? (
isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
)
) : (
<Tag className="h-4 w-4 shrink-0 text-blue-500" />
)}
{/* 카테고리 이름 */}
<span className="truncate">{category.categoryName}</span>
{/* prefix 표시 */}
<span
className={cn(
"ml-auto text-xs",
isSelected ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{category.keyPrefix}
</span>
</div>
{/* 자식 카테고리 */}
{hasChildren && isExpanded && (
<div>
{category.children!.map((child) => (
<CategoryNode
key={child.categoryId}
category={child}
level={level + 1}
selectedCategoryId={selectedCategoryId}
onSelectCategory={onSelectCategory}
onDoubleClickCategory={onDoubleClickCategory}
/>
))}
</div>
)}
</div>
);
}
export function CategoryTree({
selectedCategoryId,
onSelectCategory,
onDoubleClickCategory,
}: CategoryTreeProps) {
const [categories, setCategories] = useState<LangCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
try {
setLoading(true);
const response = await getCategories();
if (response.success && response.data) {
setCategories(response.data);
} else {
setError(response.error?.details || "카테고리 로드 실패");
}
} catch (err) {
setError("카테고리 로드 중 오류 발생");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex h-32 items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
...
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-32 items-center justify-center">
<div className="text-sm text-destructive">{error}</div>
</div>
);
}
if (categories.length === 0) {
return (
<div className="flex h-32 items-center justify-center">
<div className="text-sm text-muted-foreground">
</div>
</div>
);
}
return (
<div className="space-y-0.5">
{/* 전체 선택 옵션 */}
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
selectedCategoryId === null
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
onClick={() => onSelectCategory(null)}
>
<Folder className="h-4 w-4 shrink-0" />
<span></span>
</div>
{/* 카테고리 트리 */}
{categories.map((category) => (
<CategoryNode
key={category.categoryId}
category={category}
level={0}
selectedCategoryId={selectedCategoryId}
onSelectCategory={onSelectCategory}
onDoubleClickCategory={onDoubleClickCategory}
/>
))}
</div>
);
}
export default CategoryTree;

View File

@ -0,0 +1,497 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
LangCategory,
Language,
generateKey,
previewKey,
createOverrideKey,
getLanguages,
getCategoryPath,
KeyPreview,
} from "@/lib/api/multilang";
import { apiClient } from "@/lib/api/client";
interface Company {
companyCode: string;
companyName: string;
}
interface KeyGenerateModalProps {
isOpen: boolean;
onClose: () => void;
selectedCategory: LangCategory | null;
companyCode: string;
isSuperAdmin: boolean;
onSuccess: () => void;
}
export function KeyGenerateModal({
isOpen,
onClose,
selectedCategory,
companyCode,
isSuperAdmin,
onSuccess,
}: KeyGenerateModalProps) {
// 상태
const [keyMeaning, setKeyMeaning] = useState("");
const [usageNote, setUsageNote] = useState("");
const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode);
const [languages, setLanguages] = useState<Language[]>([]);
const [texts, setTexts] = useState<Record<string, string>>({});
const [categoryPath, setCategoryPath] = useState<LangCategory[]>([]);
const [preview, setPreview] = useState<KeyPreview | null>(null);
const [loading, setLoading] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
const [companySearchOpen, setCompanySearchOpen] = useState(false);
// 초기화
useEffect(() => {
if (isOpen) {
setKeyMeaning("");
setUsageNote("");
setTargetCompanyCode(isSuperAdmin ? "*" : companyCode);
setTexts({});
setPreview(null);
setError(null);
loadLanguages();
if (isSuperAdmin) {
loadCompanies();
}
if (selectedCategory) {
loadCategoryPath(selectedCategory.categoryId);
} else {
setCategoryPath([]);
}
}
}, [isOpen, selectedCategory, companyCode, isSuperAdmin]);
// 회사 목록 로드 (최고관리자 전용)
const loadCompanies = async () => {
try {
const response = await apiClient.get("/admin/companies");
if (response.data.success && response.data.data) {
// snake_case를 camelCase로 변환하고 공통(*)은 제외
const companyList = response.data.data
.filter((c: any) => c.company_code !== "*")
.map((c: any) => ({
companyCode: c.company_code,
companyName: c.company_name,
}));
setCompanies(companyList);
}
} catch (err) {
console.error("회사 목록 로드 실패:", err);
}
};
// 언어 목록 로드
const loadLanguages = async () => {
const response = await getLanguages();
if (response.success && response.data) {
const activeLanguages = response.data.filter((l) => l.isActive === "Y");
setLanguages(activeLanguages);
// 초기 텍스트 상태 설정
const initialTexts: Record<string, string> = {};
activeLanguages.forEach((lang) => {
initialTexts[lang.langCode] = "";
});
setTexts(initialTexts);
}
};
// 카테고리 경로 로드
const loadCategoryPath = async (categoryId: number) => {
const response = await getCategoryPath(categoryId);
if (response.success && response.data) {
setCategoryPath(response.data);
}
};
// 키 미리보기 (디바운스)
const loadPreview = useCallback(async () => {
if (!selectedCategory || !keyMeaning.trim()) {
setPreview(null);
return;
}
setPreviewLoading(true);
try {
const response = await previewKey(
selectedCategory.categoryId,
keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
targetCompanyCode
);
if (response.success && response.data) {
setPreview(response.data);
}
} catch (err) {
console.error("키 미리보기 실패:", err);
} finally {
setPreviewLoading(false);
}
}, [selectedCategory, keyMeaning, targetCompanyCode]);
// keyMeaning 변경 시 디바운스로 미리보기 로드
useEffect(() => {
const timer = setTimeout(loadPreview, 500);
return () => clearTimeout(timer);
}, [loadPreview]);
// 텍스트 변경 핸들러
const handleTextChange = (langCode: string, value: string) => {
setTexts((prev) => ({ ...prev, [langCode]: value }));
};
// 저장 핸들러
const handleSave = async () => {
if (!selectedCategory) {
setError("카테고리를 선택해주세요");
return;
}
if (!keyMeaning.trim()) {
setError("키 의미를 입력해주세요");
return;
}
// 최소 하나의 텍스트 입력 검증
const hasText = Object.values(texts).some((t) => t.trim());
if (!hasText) {
setError("최소 하나의 언어에 대한 텍스트를 입력해주세요");
return;
}
setLoading(true);
setError(null);
try {
// 오버라이드 모드인지 확인
if (preview?.isOverride && preview.baseKeyId) {
// 오버라이드 키 생성
const response = await createOverrideKey({
companyCode: targetCompanyCode,
baseKeyId: preview.baseKeyId,
texts: Object.entries(texts)
.filter(([_, text]) => text.trim())
.map(([langCode, langText]) => ({ langCode, langText })),
});
if (response.success) {
onSuccess();
onClose();
} else {
setError(response.error?.details || "오버라이드 키 생성 실패");
}
} else {
// 새 키 생성
const response = await generateKey({
companyCode: targetCompanyCode,
categoryId: selectedCategory.categoryId,
keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
usageNote: usageNote.trim() || undefined,
texts: Object.entries(texts)
.filter(([_, text]) => text.trim())
.map(([langCode, langText]) => ({ langCode, langText })),
});
if (response.success) {
onSuccess();
onClose();
} else {
setError(response.error?.details || "키 생성 실패");
}
}
} catch (err: any) {
setError(err.message || "키 생성 중 오류 발생");
} finally {
setLoading(false);
}
};
// 생성될 키 미리보기
const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim()
? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".")
: "";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{preview?.isOverride
? "공통 키에 대한 회사별 오버라이드를 생성합니다"
: "새로운 다국어 키를 자동으로 생성합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 카테고리 경로 표시 */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<div className="mt-1 flex flex-wrap gap-1">
{categoryPath.length > 0 ? (
categoryPath.map((cat, idx) => (
<span key={cat.categoryId} className="flex items-center">
<Badge variant="secondary" className="text-xs">
{cat.categoryName}
</Badge>
{idx < categoryPath.length - 1 && (
<span className="mx-1 text-muted-foreground">/</span>
)}
</span>
))
) : (
<span className="text-sm text-muted-foreground">
</span>
)}
</div>
</div>
{/* 키 의미 입력 */}
<div>
<Label htmlFor="keyMeaning" className="text-xs sm:text-sm">
*
</Label>
<Input
id="keyMeaning"
value={keyMeaning}
onChange={(e) => setKeyMeaning(e.target.value)}
placeholder="예: add_new_item, search_button, save_success"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
(_)
</p>
</div>
{/* 생성될 키 미리보기 */}
{generatedKeyPreview && (
<div className={cn(
"rounded-md border p-3",
preview?.exists
? "border-destructive bg-destructive/10"
: preview?.isOverride
? "border-blue-500 bg-blue-500/10"
: "border-green-500 bg-green-500/10"
)}>
<div className="flex items-center gap-2">
{previewLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : preview?.exists ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : preview?.isOverride ? (
<Info className="h-4 w-4 text-blue-500" />
) : (
<CheckCircle2 className="h-4 w-4 text-green-500" />
)}
<code className="text-xs font-mono sm:text-sm">
{generatedKeyPreview}
</code>
</div>
{preview?.exists && (
<p className="mt-1 text-xs text-destructive">
</p>
)}
{preview?.isOverride && !preview?.exists && (
<p className="mt-1 text-xs text-blue-600">
. .
</p>
)}
</div>
)}
{/* 대상 회사 선택 (최고 관리자만) */}
{isSuperAdmin && (
<div>
<Label className="text-xs sm:text-sm"></Label>
<div className="mt-1">
<Popover open={companySearchOpen} onOpenChange={setCompanySearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={companySearchOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{targetCompanyCode === "*"
? "공통 (*) - 모든 회사 적용"
: companies.find((c) => c.companyCode === targetCompanyCode)
? `${companies.find((c) => c.companyCode === targetCompanyCode)?.companyName} (${targetCompanyCode})`
: "대상 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 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 sm:text-sm" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs sm:text-sm">
</CommandEmpty>
<CommandGroup>
<CommandItem
value="공통"
onSelect={() => {
setTargetCompanyCode("*");
setCompanySearchOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetCompanyCode === "*" ? "opacity-100" : "opacity-0"
)}
/>
(*) -
</CommandItem>
{companies.map((company) => (
<CommandItem
key={company.companyCode}
value={`${company.companyName} ${company.companyCode}`}
onSelect={() => {
setTargetCompanyCode(company.companyCode);
setCompanySearchOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetCompanyCode === company.companyCode ? "opacity-100" : "opacity-0"
)}
/>
{company.companyName} ({company.companyCode})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 사용 메모 */}
<div>
<Label htmlFor="usageNote" className="text-xs sm:text-sm">
()
</Label>
<Textarea
id="usageNote"
value={usageNote}
onChange={(e) => setUsageNote(e.target.value)}
placeholder="이 키가 어디서 사용되는지 메모"
className="h-16 resize-none text-xs sm:text-sm"
/>
</div>
{/* 번역 텍스트 입력 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<div className="mt-2 space-y-2">
{languages.map((lang) => (
<div key={lang.langCode} className="flex items-center gap-2">
<Badge variant="outline" className="w-12 justify-center text-xs">
{lang.langCode}
</Badge>
<Input
value={texts[lang.langCode] || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
placeholder={`${lang.langName} 텍스트`}
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
/>
</div>
))}
</div>
</div>
{/* 에러 메시지 */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs sm:text-sm">
{error}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={loading || !selectedCategory || !keyMeaning.trim() || preview?.exists}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : preview?.isOverride ? (
"오버라이드 생성"
) : (
"키 생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default KeyGenerateModal;

View File

@ -34,6 +34,35 @@ import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
export interface MasterDetailExcelConfig {
// 테이블 정보
masterTable?: string;
detailTable?: string;
masterKeyColumn?: string;
detailFkColumn?: string;
// 채번
numberingRuleId?: string;
// 업로드 전 사용자가 선택할 마스터 테이블 필드
masterSelectFields?: Array<{
columnName: string;
columnLabel: string;
required: boolean;
inputType: "entity" | "date" | "text" | "select";
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
// 엑셀에서 매핑할 디테일 테이블 필드
detailExcelFields?: Array<{
columnName: string;
columnLabel: string;
required: boolean;
}>;
masterDefaults?: Record<string, any>;
detailDefaults?: Record<string, any>;
}
export interface ExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -42,6 +71,24 @@ export interface ExcelUploadModalProps {
keyColumn?: string;
onSuccess?: () => void;
userId?: string;
// 마스터-디테일 지원
screenId?: number;
isMasterDetail?: boolean;
masterDetailRelation?: {
masterTable: string;
detailTable: string;
masterKeyColumn: string;
detailFkColumn: string;
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
};
// 🆕 마스터-디테일 엑셀 업로드 설정
masterDetailExcelConfig?: MasterDetailExcelConfig;
// 🆕 단일 테이블 채번 설정
numberingRuleId?: string;
numberingTargetColumn?: string;
// 🆕 업로드 후 제어 실행 설정
afterUploadFlows?: Array<{ flowId: string; order: number }>;
}
interface ColumnMapping {
@ -57,6 +104,15 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
keyColumn,
onSuccess,
userId = "guest",
screenId,
isMasterDetail = false,
masterDetailRelation,
masterDetailExcelConfig,
// 단일 테이블 채번 설정
numberingRuleId,
numberingTargetColumn,
// 업로드 후 제어 실행 설정
afterUploadFlows,
}) => {
const [currentStep, setCurrentStep] = useState(1);
@ -79,6 +135,116 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
// 🆕 엔티티 참조 데이터 로드
useEffect(() => {
console.log("🔍 엔티티 데이터 로드 체크:", {
masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
open,
isMasterDetail,
});
if (!masterDetailExcelConfig?.masterSelectFields) return;
const loadEntityData = async () => {
const { apiClient } = await import("@/lib/api/client");
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
for (const field of masterDetailExcelConfig.masterSelectFields!) {
console.log("🔍 필드 처리:", field);
if (field.inputType === "entity") {
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
try {
let refTable = field.referenceTable;
console.log("🔍 초기 refTable:", refTable);
let displayCol = field.displayColumn;
// referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
const colResponse = await apiClient.get(
`/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
);
console.log("🔍 컬럼 조회 응답:", colResponse.data);
if (colResponse.data?.success && colResponse.data?.data?.columns) {
const colInfo = colResponse.data.data.columns.find(
(c: any) => (c.columnName || c.column_name) === field.columnName
);
console.log("🔍 찾은 컬럼 정보:", colInfo);
if (colInfo) {
if (!refTable) {
refTable = colInfo.referenceTable || colInfo.reference_table;
console.log("🔍 DB에서 가져온 refTable:", refTable);
}
if (!displayCol) {
displayCol = colInfo.displayColumn || colInfo.display_column;
console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
}
}
}
}
// displayColumn 저장 (Select 렌더링 시 사용)
if (displayCol) {
setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
}
if (refTable) {
console.log("🔍 엔티티 데이터 조회:", refTable);
const response = await DynamicFormApi.getTableData(refTable, {
page: 1,
pageSize: 1000,
});
console.log("🔍 엔티티 데이터 응답:", response);
// getTableData는 { success, data: [...] } 형식으로 반환
const rows = response.data?.rows || response.data;
if (response.success && rows && Array.isArray(rows)) {
setEntitySearchData((prev) => ({
...prev,
[field.columnName]: rows,
}));
console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
}
} else {
console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
}
} catch (error) {
console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
} finally {
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
}
}
}
};
if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
loadEntityData();
}
}, [open, isMasterDetail, masterDetailExcelConfig]);
// 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
const hasMasterSelectFields = isSimpleMasterDetailMode &&
(masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
// 마스터 필드가 모두 입력되었는지 확인
const isMasterFieldsValid = () => {
if (!hasMasterSelectFields) return true;
return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
if (!field.required) return true;
const value = masterFieldValues[field.columnName];
return value !== undefined && value !== null && value !== "";
});
};
// 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
@ -184,50 +350,138 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const loadTableSchema = async () => {
try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
const response = await getTableSchema(tableName);
let allColumns: TableColumn[] = [];
console.log("📊 테이블 스키마 응답:", response);
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
if (isSimpleMasterDetailMode && masterDetailRelation) {
const { detailTable, detailFkColumn } = masterDetailRelation;
if (response.success && response.data) {
// 자동 생성 컬럼 제외
const filteredColumns = response.data.columns.filter(
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
);
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
setSystemColumns(filteredColumns);
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
const detailResponse = await getTableSchema(detailTable);
if (detailResponse.success && detailResponse.data) {
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
const configuredFields = masterDetailExcelConfig?.detailExcelFields;
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
const detailCols = detailResponse.data.columns
.filter((col) => {
// 자동 생성 컬럼, FK 컬럼 제외
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
if (col.name === detailFkColumn) return false;
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else {
// 매핑 템플릿이 없으면 초기 상태로 설정
console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
// 설정된 필드가 있으면 해당 필드만
if (configuredFields && configuredFields.length > 0) {
return configuredFields.some((f) => f.columnName === col.name);
}
return true;
})
.map((col) => {
// 설정에서 라벨 찾기
const configField = configuredFields?.find((f) => f.columnName === col.name);
return {
...col,
label: configField?.columnLabel || col.label || col.name,
originalName: col.name,
sourceTable: detailTable,
};
});
allColumns = detailCols;
}
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
}
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
else if (isMasterDetail && masterDetailRelation) {
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
// 마스터 테이블 스키마
const masterResponse = await getTableSchema(masterTable);
if (masterResponse.success && masterResponse.data) {
const masterCols = masterResponse.data.columns
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
.map((col) => ({
...col,
// 유니크 키를 위해 테이블명 접두사 추가
name: `${masterTable}.${col.name}`,
label: `[마스터] ${col.label || col.name}`,
originalName: col.name,
sourceTable: masterTable,
}));
allColumns = [...allColumns, ...masterCols];
}
// 디테일 테이블 스키마 (FK 컬럼 제외)
const detailResponse = await getTableSchema(detailTable);
if (detailResponse.success && detailResponse.data) {
const detailCols = detailResponse.data.columns
.filter((col) =>
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
col.name !== detailFkColumn // FK 컬럼 제외
)
.map((col) => ({
...col,
// 유니크 키를 위해 테이블명 접두사 추가
name: `${detailTable}.${col.name}`,
label: `[디테일] ${col.label || col.name}`,
originalName: col.name,
sourceTable: detailTable,
}));
allColumns = [...allColumns, ...detailCols];
}
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
// 기존 단일 테이블 모드
const response = await getTableSchema(tableName);
console.log("📊 테이블 스키마 응답:", response);
if (response.success && response.data) {
// 자동 생성 컬럼 제외
allColumns = response.data.columns.filter(
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
);
} else {
console.error("❌ 테이블 스키마 로드 실패:", response);
return;
}
}
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
setSystemColumns(allColumns);
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else {
// 매핑 템플릿이 없으면 초기 상태로 설정
console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
}
} catch (error) {
console.error("❌ 테이블 스키마 로드 실패:", error);
@ -239,18 +493,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim();
// [마스터], [디테일] 접두사 제거 후 비교
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
(sysCol) =>
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
);
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
let matchedSystemCol = systemColumns.find((sysCol) => {
if (!sysCol.label) return false;
// [마스터], [디테일] 접두사 제거 후 비교
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
});
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
);
matchedSystemCol = systemColumns.find((sysCol) => {
// 마스터-디테일 모드: originalName이 있으면 사용
const originalName = (sysCol as any).originalName;
const colName = originalName || sysCol.name;
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
});
}
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find((sysCol) => {
// 테이블.컬럼 형식에서 컬럼만 추출
const nameParts = sysCol.name.split(".");
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
});
}
return {
@ -285,6 +556,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return;
}
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
toast.error("마스터 정보를 모두 입력해주세요.");
return;
}
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
if (currentStep === 1) {
// 빈 헤더가 아닌 열만 필터링
@ -344,7 +621,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
let colName = mapping.systemColumn;
if (isMasterDetail && colName.includes(".")) {
colName = colName.split(".")[1];
}
mappedRow[colName] = row[mapping.excelColumn];
}
});
return mappedRow;
@ -364,60 +646,133 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
);
let successCount = 0;
let failCount = 0;
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 간단 모드 업로드:", {
masterDetailRelation,
masterFieldValues,
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
});
for (const row of filteredData) {
try {
if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
} catch (error) {
failCount++;
}
}
if (successCount > 0) {
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
screenId,
filteredData,
masterFieldValues,
masterDetailExcelConfig?.numberingRuleId || undefined,
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
);
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
try {
const mappingsToSave: Record<string, string | null> = {};
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
if (uploadResult.success && uploadResult.data) {
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
toast.success(
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
);
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
}
}
// 🆕 마스터-디테일 기존 모드 처리
else if (isMasterDetail && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
screenId,
filteredData
);
if (uploadResult.success && uploadResult.data) {
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
toast.success(
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
}
} else {
// 기존 단일 테이블 업로드 로직
let successCount = 0;
let failCount = 0;
// 단일 테이블 채번 설정 확인
const hasNumbering = numberingRuleId && numberingTargetColumn;
for (const row of filteredData) {
try {
let dataToSave = { ...row };
// 채번 적용: 각 행마다 채번 API 호출
if (hasNumbering && uploadMode === "insert") {
try {
const { apiClient } = await import("@/lib/api/client");
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingTargetColumn] = generatedCode;
}
} catch (numError) {
console.error("채번 오류:", numError);
}
}
if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: dataToSave };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
} catch (error) {
failCount++;
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
// 🆕 업로드 후 제어 실행
if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
try {
const { apiClient } = await import("@/lib/api/client");
// 순서대로 실행
const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order);
for (const flow of sortedFlows) {
await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, {
sourceData: { tableName, uploadedCount: successCount },
});
console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`);
}
} catch (controlError) {
console.error("제어 실행 오류:", controlError);
}
}
if (successCount > 0) {
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
}
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
@ -427,6 +782,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
};
// 매핑 템플릿 저장 헬퍼 함수
const saveMappingTemplateInternal = async () => {
try {
const mappingsToSave: Record<string, string | null> = {};
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
);
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
@ -441,6 +825,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
// 🆕 마스터-디테일 모드 초기화
setMasterFieldValues({});
}
}, [open]);
@ -461,9 +847,21 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" />
{isMasterDetail && (
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
-
</span>
)}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
{isMasterDetail && masterDetailRelation ? (
<>
({masterDetailRelation.masterTable}) + ({masterDetailRelation.detailTable}) .
.
</>
) : (
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
)}
</DialogDescription>
</DialogHeader>
@ -518,6 +916,87 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && (
<div className="space-y-4">
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
{hasMasterSelectFields && (
<div className="grid gap-3 sm:grid-cols-2">
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
<div key={field.columnName} className="space-y-1">
<Label className="text-xs">
{field.columnLabel}
{field.required && <span className="ml-1 text-destructive">*</span>}
</Label>
{field.inputType === "entity" ? (
<Select
value={masterFieldValues[field.columnName]?.toString() || ""}
onValueChange={(value) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: value,
}))
}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder={`${field.columnLabel} 선택`} />
</SelectTrigger>
<SelectContent>
{entitySearchLoading[field.columnName] ? (
<SelectItem value="loading" disabled>
...
</SelectItem>
) : (
entitySearchData[field.columnName]?.map((item: any) => {
const keyValue = item[field.referenceColumn || "id"];
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
const displayColName =
field.displayColumn ||
entityDisplayColumns[field.columnName] ||
field.referenceColumn ||
"id";
const displayValue = item[displayColName] || keyValue;
return (
<SelectItem
key={keyValue}
value={keyValue?.toString()}
className="text-xs"
>
{displayValue}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
) : field.inputType === "date" ? (
<input
type="date"
value={masterFieldValues[field.columnName] || ""}
onChange={(e) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: e.target.value,
}))
}
className="h-9 w-full rounded-md border px-3 text-xs"
/>
) : (
<input
type="text"
value={masterFieldValues[field.columnName] || ""}
onChange={(e) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: e.target.value,
}))
}
placeholder={field.columnLabel}
className="h-9 w-full rounded-md border px-3 text-xs"
/>
)}
</div>
))}
</div>
)}
{/* 파일 선택 영역 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">

View File

@ -175,13 +175,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
// 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
if (Array.isArray(editData)) {
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
const firstRecord = editData[0] || {};
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, {
formData: "첫 번째 레코드 (일반 입력 필드용)",
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
});
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
} else {
setFormData(editData);
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
} else {

View File

@ -65,6 +65,10 @@ const nodeTypes = {
*/
interface FlowEditorInnerProps {
initialFlowId?: number | null;
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
/** 임베디드 모드 여부 */
embedded?: boolean;
}
// 플로우 에디터 툴바 버튼 설정
@ -87,7 +91,7 @@ const flowToolbarButtons: ToolbarButton[] = [
},
];
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition, setCenter } = useReactFlow();
@ -385,7 +389,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
{/* 상단 툴바 */}
<Panel position="top-center" className="pointer-events-auto">
<FlowToolbar validations={validations} />
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
</Panel>
</ReactFlow>
</div>
@ -416,13 +420,21 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
*/
interface FlowEditorProps {
initialFlowId?: number | null;
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
embedded?: boolean;
}
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
return (
<div className="h-full w-full">
<ReactFlowProvider>
<FlowEditorInner initialFlowId={initialFlowId} />
<FlowEditorInner
initialFlowId={initialFlowId}
onSaveComplete={onSaveComplete}
embedded={embedded}
/>
</ReactFlowProvider>
</div>
);

View File

@ -17,9 +17,11 @@ import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
}
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
@ -59,13 +61,27 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const result = await saveFlow();
if (result.success) {
toast({
title: "✅ 플로우 저장 완료",
title: "저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
variant: "default",
});
// 임베디드 모드에서 저장 완료 콜백 호출
if (onSaveComplete && result.flowId) {
onSaveComplete(result.flowId, flowName);
}
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
if (window.opener && result.flowId) {
window.opener.postMessage({
type: "FLOW_SAVED",
flowId: result.flowId,
flowName: flowName,
}, "*");
}
} else {
toast({
title: "❌ 저장 실패",
title: "저장 실패",
description: result.message,
variant: "destructive",
});

View File

@ -28,6 +28,14 @@ const OPERATOR_LABELS: Record<string, string> = {
"%": "%",
};
// 피연산자를 문자열로 변환
function getOperandStr(operand: any): string {
if (!operand) return "?";
if (operand.type === "static") return String(operand.value || "?");
if (operand.fieldLabel) return operand.fieldLabel;
return operand.field || operand.resultField || "?";
}
// 수식 요약 생성
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
@ -35,11 +43,19 @@ function getFormulaSummary(transformation: FormulaTransformNodeData["transformat
switch (formulaType) {
case "arithmetic": {
if (!arithmetic) return "미설정";
const left = arithmetic.leftOperand;
const right = arithmetic.rightOperand;
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
const leftStr = getOperandStr(arithmetic.leftOperand);
const rightStr = getOperandStr(arithmetic.rightOperand);
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
// 추가 연산 표시
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
for (const addOp of arithmetic.additionalOperations) {
const opStr = getOperandStr(addOp.operand);
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
}
}
return formula;
}
case "function": {
if (!func) return "미설정";

View File

@ -797,6 +797,85 @@ export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPro
index,
)}
</div>
{/* 추가 연산 목록 */}
{trans.arithmetic.additionalOperations && trans.arithmetic.additionalOperations.length > 0 && (
<div className="space-y-2 border-t pt-2">
<Label className="text-xs text-gray-500"> </Label>
{trans.arithmetic.additionalOperations.map((addOp: any, addIndex: number) => (
<div key={addIndex} className="flex items-center gap-2 rounded bg-orange-50 p-2">
<Select
value={addOp.operator}
onValueChange={(value) => {
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operator: value };
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
}}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ARITHMETIC_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.value}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1">
{renderOperandSelector(
addOp.operand,
(updates) => {
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operand: updates };
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
},
index,
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const newAdditionalOps = trans.arithmetic!.additionalOperations!.filter(
(_: any, i: number) => i !== addIndex
);
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 추가 연산 버튼 */}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
const newAdditionalOps = [
...(trans.arithmetic!.additionalOperations || []),
{ operator: "*", operand: { type: "static" as const, value: "" } },
];
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
)}

View File

@ -5,7 +5,7 @@
*/
import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -18,6 +18,8 @@ import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getNumberingRules } from "@/lib/api/numberingRule";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
import type { InsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@ -89,6 +91,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
// 🔥 채번 규칙 관련 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || data.targetTable);
@ -128,8 +135,33 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
useEffect(() => {
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
}, [fieldMappings.length]);
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
useEffect(() => {
const loadNumberingRules = async () => {
setNumberingRulesLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setNumberingRules(response.data);
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
} else {
console.error("❌ 채번 규칙 로딩 실패:", response.error);
setNumberingRules([]);
}
} catch (error) {
console.error("❌ 채번 규칙 로딩 오류:", error);
setNumberingRules([]);
} finally {
setNumberingRulesLoading(false);
}
};
loadNumberingRules();
}, []);
// 🔥 외부 테이블 변경 시 컬럼 로드
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
@ -540,6 +572,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
sourceField: null,
targetField: "",
staticValue: undefined,
valueType: "source" as const, // 🔥 기본값: 소스 필드
},
];
setFieldMappings(newMappings);
@ -548,6 +581,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// Combobox 열림 상태 배열 초기화
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
};
const handleRemoveMapping = (index: number) => {
@ -558,6 +592,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// Combobox 열림 상태 배열도 업데이트
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
};
const handleMappingChange = (index: number, field: string, value: any) => {
@ -586,6 +621,24 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
targetField: value,
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
};
} else if (field === "valueType") {
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
newMappings[index] = {
...newMappings[index],
valueType: value,
// 유형 변경 시 다른 유형의 값 초기화
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
...(value !== "static" && { staticValue: undefined }),
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
};
} else if (field === "numberingRuleId") {
// 🔥 채번 규칙 선택 시 이름도 함께 저장
const selectedRule = numberingRules.find((r) => r.ruleId === value);
newMappings[index] = {
...newMappings[index],
numberingRuleId: value,
numberingRuleName: selectedRule?.ruleName,
};
} else {
newMappings[index] = {
...newMappings[index],
@ -1165,54 +1218,203 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</div>
<div className="space-y-2">
{/* 소스 필드 입력/선택 */}
{/* 🔥 값 생성 유형 선택 */}
<div>
<Label className="text-xs text-gray-600">
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - )</span>}
</Label>
{hasRestAPISource ? (
// REST API 소스인 경우: 직접 입력
<Label className="text-xs text-gray-600"> </Label>
<div className="mt-1 grid grid-cols-3 gap-1">
<button
type="button"
onClick={() => handleMappingChange(index, "valueType", "source")}
className={cn(
"rounded border px-2 py-1 text-xs transition-all",
(mapping.valueType === "source" || !mapping.valueType)
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-200 hover:border-gray-300",
)}
>
</button>
<button
type="button"
onClick={() => handleMappingChange(index, "valueType", "static")}
className={cn(
"rounded border px-2 py-1 text-xs transition-all",
mapping.valueType === "static"
? "border-orange-500 bg-orange-50 text-orange-700"
: "border-gray-200 hover:border-gray-300",
)}
>
</button>
<button
type="button"
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
className={cn(
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
mapping.valueType === "autoGenerate"
? "border-purple-500 bg-purple-50 text-purple-700"
: "border-gray-200 hover:border-gray-300",
)}
>
<Sparkles className="h-3 w-3" />
</button>
</div>
</div>
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
{(mapping.valueType === "source" || !mapping.valueType) && (
<div>
<Label className="text-xs text-gray-600">
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - )</span>}
</Label>
{hasRestAPISource ? (
// REST API 소스인 경우: 직접 입력
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="필드명 입력 (예: userId, userName)"
className="mt-1 h-8 text-xs"
/>
) : (
// 일반 소스인 경우: Combobox 선택
<Popover
open={mappingSourceFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...mappingSourceFieldsOpenState];
newState[index] = open;
setMappingSourceFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mappingSourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{mapping.sourceField
? (() => {
const field = sourceFields.find((f) => f.name === mapping.sourceField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-medium">
{field?.label || mapping.sourceField}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">
{field.name}
</span>
)}
</div>
);
})()
: "소스 필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 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 sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{sourceFields.map((field) => (
<CommandItem
key={field.name}
value={field.name}
onSelect={(currentValue) => {
handleMappingChange(index, "sourceField", currentValue || null);
const newState = [...mappingSourceFieldsOpenState];
newState[index] = false;
setMappingSourceFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
{hasRestAPISource && (
<p className="mt-1 text-xs text-gray-500">API JSON의 </p>
)}
</div>
)}
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
{mapping.valueType === "static" && (
<div>
<Label className="text-xs text-gray-600"></Label>
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="필드명 입력 (예: userId, userName)"
value={mapping.staticValue || ""}
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
placeholder="고정값 입력"
className="mt-1 h-8 text-xs"
/>
) : (
// 일반 소스인 경우: Combobox 선택
</div>
)}
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
{mapping.valueType === "autoGenerate" && (
<div>
<Label className="text-xs text-gray-600">
{numberingRulesLoading && <span className="ml-1 text-gray-400">( ...)</span>}
</Label>
<Popover
open={mappingSourceFieldsOpenState[index]}
open={mappingNumberingRulesOpenState[index]}
onOpenChange={(open) => {
const newState = [...mappingSourceFieldsOpenState];
const newState = [...mappingNumberingRulesOpenState];
newState[index] = open;
setMappingSourceFieldsOpenState(newState);
setMappingNumberingRulesOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mappingSourceFieldsOpenState[index]}
aria-expanded={mappingNumberingRulesOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={numberingRulesLoading || numberingRules.length === 0}
>
{mapping.sourceField
{mapping.numberingRuleId
? (() => {
const field = sourceFields.find((f) => f.name === mapping.sourceField);
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
<Sparkles className="h-3 w-3 text-purple-500" />
<span className="truncate font-medium">
{field?.label || mapping.sourceField}
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">
{field.name}
</span>
)}
</div>
);
})()
: "소스 필드 선택"}
: "채번 규칙 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -1222,37 +1424,36 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
align="start"
>
<Command>
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
.
</CommandEmpty>
<CommandGroup>
{sourceFields.map((field) => (
{numberingRules.map((rule) => (
<CommandItem
key={field.name}
value={field.name}
key={rule.ruleId}
value={rule.ruleId}
onSelect={(currentValue) => {
handleMappingChange(index, "sourceField", currentValue || null);
const newState = [...mappingSourceFieldsOpenState];
handleMappingChange(index, "numberingRuleId", currentValue);
const newState = [...mappingNumberingRulesOpenState];
newState[index] = false;
setMappingSourceFieldsOpenState(newState);
setMappingNumberingRulesOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
<span className="font-medium">{rule.ruleName}</span>
<span className="text-muted-foreground font-mono text-[10px]">
{rule.ruleId}
{rule.tableName && ` - ${rule.tableName}`}
</span>
</div>
</CommandItem>
))}
@ -1261,11 +1462,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</Command>
</PopoverContent>
</Popover>
)}
{hasRestAPISource && (
<p className="mt-1 text-xs text-gray-500">API JSON의 </p>
)}
</div>
{numberingRules.length === 0 && !numberingRulesLoading && (
<p className="mt-1 text-xs text-orange-600">
. .
</p>
)}
</div>
)}
<div className="flex items-center justify-center py-1">
<ArrowRight className="h-4 w-4 text-green-600" />
@ -1400,18 +1603,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</PopoverContent>
</Popover>
</div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Input
value={mapping.staticValue || ""}
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
placeholder="소스 필드 대신 고정 값 사용"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</div>
</div>
))}
@ -1428,9 +1619,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
{/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
.
<br />
💡 .
<p> .</p>
<p className="mt-1"> 방식: 소스 ( ) / ( ) / ( )</p>
</div>
</div>
</div>

View File

@ -302,6 +302,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
// 현재 모드에 따라 표시할 메뉴 결정
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
const currentMenus = isAdminMode ? adminMenus : userMenus;
@ -458,6 +461,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
if (isPreviewMode) {
return (
<div className="h-screen w-full overflow-auto bg-white p-4">
{children}
</div>
);
}
// UI 변환된 메뉴 데이터
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@ -15,6 +16,14 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
import { ProfileFormData } from "@/types/profile";
import { Separator } from "@/components/ui/separator";
import { VehicleRegisterData } from "@/lib/api/driver";
import { apiClient } from "@/lib/api/client";
// 언어 정보 타입
interface LanguageInfo {
langCode: string;
langName: string;
langNative: string;
}
// 운전자 정보 타입
export interface DriverInfo {
@ -148,6 +157,46 @@ export function ProfileModal({
onSave,
onAlertClose,
}: ProfileModalProps) {
// 언어 목록 상태
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
// 언어 목록 로드
useEffect(() => {
const loadLanguages = async () => {
try {
const response = await apiClient.get("/multilang/languages");
if (response.data?.success && response.data?.data) {
// is_active가 'Y'인 언어만 필터링하고 정렬
const activeLanguages = response.data.data
.filter((lang: any) => lang.isActive === "Y" || lang.is_active === "Y")
.map((lang: any) => ({
langCode: lang.langCode || lang.lang_code,
langName: lang.langName || lang.lang_name,
langNative: lang.langNative || lang.lang_native,
}))
.sort((a: LanguageInfo, b: LanguageInfo) => {
// KR을 먼저 표시
if (a.langCode === "KR") return -1;
if (b.langCode === "KR") return 1;
return a.langCode.localeCompare(b.langCode);
});
setLanguages(activeLanguages);
}
} catch (error) {
console.error("언어 목록 로드 실패:", error);
// 기본값 설정
setLanguages([
{ langCode: "KR", langName: "Korean", langNative: "한국어" },
{ langCode: "US", langName: "English", langNative: "English" },
]);
}
};
if (isOpen) {
loadLanguages();
}
}, [isOpen]);
// 차량 상태 한글 변환
const getStatusLabel = (status: string | null) => {
switch (status) {
@ -293,10 +342,15 @@ export function ProfileModal({
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="KR"> (KR)</SelectItem>
<SelectItem value="US">English (US)</SelectItem>
<SelectItem value="JP"> (JP)</SelectItem>
<SelectItem value="CN"> (CN)</SelectItem>
{languages.length > 0 ? (
languages.map((lang) => (
<SelectItem key={lang.langCode} value={lang.langCode}>
{lang.langNative} ({lang.langCode})
</SelectItem>
))
) : (
<SelectItem value="KR"> (KR)</SelectItem>
)}
</SelectContent>
</Select>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ import {
Layout,
Monitor,
Square,
Languages,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -34,6 +35,8 @@ interface DesignerToolbarProps {
isSaving?: boolean;
showZoneBorders?: boolean;
onToggleZoneBorders?: () => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
}
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
@ -50,6 +53,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
isSaving = false,
showZoneBorders = true,
onToggleZoneBorders,
onGenerateMultilang,
isGeneratingMultilang = false,
}) => {
return (
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
@ -226,6 +231,20 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
<div className="h-6 w-px bg-gray-300" />
{onGenerateMultilang && (
<Button
variant="outline"
size="sm"
onClick={onGenerateMultilang}
disabled={isGeneratingMultilang}
className="flex items-center space-x-1"
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
>
<Languages className="h-4 w-4" />
<span className="hidden sm:inline">{isGeneratingMultilang ? "생성 중..." : "다국어"}</span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>

View File

@ -309,17 +309,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => {
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
// console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
return;
}
try {
// console.log("🔍 그룹 데이터 조회 시작:", {
// tableName: modalState.tableName,
// groupByColumns: modalState.groupByColumns,
// editData: modalState.editData,
// });
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
const groupValues: Record<string, any> = {};
modalState.groupByColumns.forEach((column) => {
@ -329,15 +322,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
if (Object.keys(groupValues).length === 0) {
// console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
return;
}
// console.log("🔍 그룹 조회 요청:", {
// tableName: modalState.tableName,
// groupValues,
// });
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
@ -347,23 +334,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
enableEntityJoin: true,
});
// console.log("🔍 그룹 조회 응답:", response);
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) {
// console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
} else {
console.warn("그룹 데이터가 없습니다:", response);
setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
}
} catch (error: any) {
console.error("그룹 데이터 조회 오류:", error);
console.error("그룹 데이터 조회 오류:", error);
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
@ -671,9 +654,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.log("🗑️ 품목 삭제:", deletedItem);
try {
// screenId 전달하여 제어관리 실행 가능하도록 함
const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id,
screenData.screenInfo.tableName,
modalState.screenId || screenData.screenInfo?.id,
);
if (response.success) {
@ -1041,18 +1026,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
if (c.componentType === "universal-form-modal") return true;
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
if (c.componentType === "conditional-container") return true;
return false;
}
);
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
const shouldUseEditModalSave = groupData.length > 0 || !hasUniversalFormModal;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
@ -1093,9 +1079,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
onSave={hasUniversalFormModal ? undefined : handleSave}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp}

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -42,7 +43,7 @@ import {
} from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCurrentUser, UserInfo } from "@/lib/api/client";
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils";
@ -100,11 +101,7 @@ const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
const isDisabled = !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
@ -188,6 +185,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
const searchParams = useSearchParams();
const menuObjid = useMemo(() => {
// 1. ScreenContext에서 가져오기
if (screenContext?.menuObjid) return screenContext.menuObjid;
// 2. URL 쿼리에서 가져오기
const urlMenuObjid = searchParams.get("menuObjid");
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
}, [screenContext?.menuObjid, searchParams]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
@ -236,7 +243,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
// 테이블 등록 (Context에 등록)
const tableId = `datatable-${component.id}`;
@ -365,8 +377,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
for (const col of categoryColumns) {
try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const response = await apiClient.get(
`/table-categories/${component.tableName}/${col.columnName}/values`
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
);
if (response.data.success && response.data.data) {
@ -379,7 +393,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
});
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
}
} catch (error) {
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
@ -394,7 +408,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
loadCategoryMappings();
}, [component.tableName, component.columns, getColumnWebType]);
}, [component.tableName, component.columns, getColumnWebType, menuObjid]);
// 파일 상태 확인 함수
const checkFileStatus = useCallback(
@ -701,13 +715,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
filter.targetColumn === component.tableName
(filter) =>
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
hasSelectedLeftData =
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
@ -741,7 +755,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
// 🆕 RelatedDataButtons 필터 적용
let relatedButtonFilterValues: Record<string, any> = {};
const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
@ -773,7 +787,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
tableName: component.tableName,
dataLength: result.data.length,
total: result.total,
page: result.page
page: result.page,
});
setData(result.data);
@ -781,6 +795,45 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setTotalPages(result.totalPages);
setCurrentPage(result.page);
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
const detectAndLoadCategoryLabels = async () => {
const categoryCodes = new Set<string>();
result.data.forEach((row: Record<string, any>) => {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
});
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes);
if (newCodes.length > 0) {
try {
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
if (response.data.success && response.data.data) {
setCategoryCodeLabels((prev) => {
const newLabels = {
...prev,
...response.data.data,
};
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
return newLabels;
});
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
detectAndLoadCategoryLabels();
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
const primaryKeyField = Object.keys(rowData)[0];
@ -918,12 +971,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setTableColumns(columns);
// 🆕 전체 컬럼 목록 설정
const columnNames = columns.map(col => col.columnName);
const columnNames = columns.map((col) => col.columnName);
setAllAvailableColumns(columnNames);
// 🆕 컬럼명 -> 라벨 매핑 생성
const labels: Record<string, string> = {};
columns.forEach(col => {
columns.forEach((col) => {
labels[col.columnName] = col.displayName || col.columnName;
});
setColumnLabels(labels);
@ -983,28 +1036,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
// 행 선택 핸들러
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
}
return newSet;
});
const handleRowSelect = useCallback(
(rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
}
}, [data, splitPanelContext, splitPanelPosition]);
},
[data, splitPanelContext, splitPanelPosition],
);
// 전체 선택/해제 핸들러
const handleSelectAll = useCallback(
@ -1713,7 +1769,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return (
<div>
<CategorySelectComponent
@ -2013,7 +2071,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return (
<div>
<CategorySelectComponent
@ -2151,8 +2211,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const actualWebType = getColumnWebType(column.columnName);
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
const isFileColumn =
actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) {
@ -2214,7 +2273,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor
borderColor: displayColor,
}}
className="text-white"
>
@ -2255,8 +2314,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
break;
default:
return String(value);
default: {
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
for (const columnName of Object.keys(categoryMappings)) {
const mapping = categoryMappings[columnName];
const categoryData = mapping?.[strValue];
if (categoryData) {
// 색상이 있으면 배지로, 없으면 텍스트로 표시
if (categoryData.color && categoryData.color !== "none") {
return (
<Badge
style={{
backgroundColor: categoryData.color,
borderColor: categoryData.color,
}}
className="text-white"
>
{categoryData.label}
</Badge>
);
}
return <span className="text-sm">{categoryData.label}</span>;
}
}
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
const cachedLabel = categoryCodeLabels[strValue];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
return strValue;
}
}
return String(value);
@ -2392,15 +2484,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.length > 0 ? (
<>
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
<Table style={{ tableLayout: 'fixed' }}>
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b">
<TableRow>
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableHead
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.size === data.length && data.length > 0}
onCheckedChange={handleSelectAll}
@ -2414,18 +2503,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<TableHead
key={column.id}
ref={(el) => (columnRefs.current[column.id] = el)}
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none"
style={{
width: columnWidth ? `${columnWidth}px` : undefined,
userSelect: 'none'
userSelect: "none",
}}
>
{column.label}
{/* 리사이즈 핸들 */}
{columnIndex < visibleColumns.length - 1 && (
<div
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => {
e.preventDefault();
@ -2440,8 +2529,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
@ -2459,24 +2548,24 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 최종 너비를 state에 저장
if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
}
// 텍스트 선택 복원
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 약간의 지연 후 리사이즈 플래그 해제
setTimeout(() => {
isResizingRef.current = false;
}, 100);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
)}
@ -2504,10 +2593,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableCell
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
@ -2519,8 +2605,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
return (
<TableCell
key={column.id}
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
style={{ textAlign: isNumeric ? 'right' : 'left' }}
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900"
style={{ textAlign: isNumeric ? "right" : "left" }}
>
{formatCellValue(row[column.columnName], column, row)}
</TableCell>

View File

@ -1369,25 +1369,58 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
case "entity": {
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
const widget = comp as WidgetComponent;
return applyStyles(
<DynamicWebTypeRenderer
webType="entity"
config={widget.webTypeConfig}
props={{
component: widget,
value: currentValue,
onChange: (value: any) => updateFormData(fieldName, value),
onFormDataChange: updateFormData,
formData: formData,
readonly: readonly,
required: required,
placeholder: widget.placeholder || "엔티티를 선택하세요",
isInteractive: true,
className: "w-full h-full",
}}
/>,
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
entityName: config?.entityName,
displayField: config?.displayField,
valueField: config?.valueField,
multiple: config?.multiple,
defaultValue: config?.defaultValue,
},
});
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
const defaultOptions = [
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
return (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{defaultOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
@ -1876,23 +1909,27 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
};
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return applyStyles(
<Button
<button
onClick={handleButtonClick}
disabled={readonly}
size="sm"
variant={config?.variant || "default"}
className="w-full"
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
style={{
height: "100%",
// 설정값이 있으면 우선 적용
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
}}
>
{label || "버튼"}
</Button>
</button>
);
}

View File

@ -365,7 +365,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isInteractive={true}
formData={formData}
originalData={originalData || undefined}
initialData={(originalData && Object.keys(originalData).length > 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
@ -835,12 +834,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
};
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
return (
<Button
<button
onClick={handleClick}
variant={(config?.variant as any) || "default"}
size={(config?.size as any) || "default"}
disabled={config?.disabled}
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
style={{
// 컴포넌트 스타일 적용
...comp.style,
@ -853,7 +858,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}}
>
{label || "버튼"}
</Button>
</button>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -637,24 +637,28 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
}
}
// 색상이 설정되어 있으면 variant 스타일을 무시하고 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return (
<div className="relative">
<Button
onClick={handleClick}
disabled={isExecuting || disabled}
variant={config?.variant || "default"}
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함
variant={hasCustomColors ? undefined : (config?.variant || "default")}
className={cn(
"transition-all duration-200",
isExecuting && "cursor-wait opacity-75",
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
config?.backgroundColor && { backgroundColor: config.backgroundColor },
config?.textColor && { color: config.textColor },
config?.borderColor && { borderColor: config.borderColor },
// 커스텀 색상이 없을 때만 기본 스타일 적용
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
)}
style={{
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
// 커스텀 색상이 있을 때만 인라인 스타일 적용
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
...(config?.textColor && { color: config.textColor }),
...(config?.borderColor && { borderColor: config.borderColor }),
}}
>
{/* 메인 버튼 내용 */}

View File

@ -17,6 +17,7 @@ import {
File,
} from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
@ -129,6 +130,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onFormDataChange,
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
}) => {
// 🆕 화면 다국어 컨텍스트
const { getTranslatedText } = useScreenMultiLang();
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const lastUpdatedHeight = React.useRef<number | null>(null);

View File

@ -78,6 +78,7 @@ import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { MultilangSettingsModal } from "./modals/MultilangSettingsModal";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
@ -144,6 +145,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
},
});
const [isSaving, setIsSaving] = useState(false);
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false);
// 🆕 화면에 할당된 메뉴 OBJID
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
@ -1447,6 +1450,101 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, [selectedScreen, layout, screenResolution]);
// 다국어 자동 생성 핸들러
const handleGenerateMultilang = useCallback(async () => {
if (!selectedScreen?.screenId) {
toast.error("화면 정보가 없습니다.");
return;
}
setIsGeneratingMultilang(true);
try {
// 공통 유틸 사용하여 라벨 추출
const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import(
"@/lib/utils/multilangLabelExtractor"
);
const { apiClient } = await import("@/lib/api/client");
// 테이블별 컬럼 라벨 로드
const tableNames = extractTableNames(layout.components);
const columnLabelMap: Record<string, Record<string, string>> = {};
for (const tableName of tableNames) {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data?.success && response.data?.data) {
const columns = response.data.data.columns || response.data.data;
if (Array.isArray(columns)) {
columnLabelMap[tableName] = {};
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name || col.name;
const colLabel = col.displayName || col.columnLabel || col.column_label || colName;
if (colName) {
columnLabelMap[tableName][colName] = colLabel;
}
});
}
}
} catch (error) {
console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error);
}
}
// 라벨 추출 (다국어 설정과 동일한 로직)
const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap);
const labels = extractedLabels.map((l) => ({
componentId: l.componentId,
label: l.label,
type: l.type,
}));
if (labels.length === 0) {
toast.info("다국어로 변환할 라벨이 없습니다.");
setIsGeneratingMultilang(false);
return;
}
// API 호출
const { generateScreenLabelKeys } = await import("@/lib/api/multilang");
const response = await generateScreenLabelKeys({
screenId: selectedScreen.screenId,
menuObjId: menuObjid?.toString(),
labels,
});
if (response.success && response.data) {
// 자동 매핑 적용
const updatedComponents = applyMultilangMappings(layout.components, response.data);
// 레이아웃 업데이트
const updatedLayout = {
...layout,
components: updatedComponents,
screenResolution: screenResolution,
};
setLayout(updatedLayout);
// 자동 저장 (매핑 정보가 손실되지 않도록)
try {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`);
} catch (saveError) {
console.error("다국어 매핑 저장 실패:", saveError);
toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`);
}
} else {
toast.error(response.error?.details || "다국어 키 생성에 실패했습니다.");
}
} catch (error) {
console.error("다국어 생성 실패:", error);
toast.error("다국어 키 생성 중 오류가 발생했습니다.");
} finally {
setIsGeneratingMultilang(false);
}
}, [selectedScreen, layout, screenResolution, menuObjid]);
// 템플릿 드래그 처리
const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => {
@ -4217,6 +4315,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
/>
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
@ -4999,6 +5100,42 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
screenId={selectedScreen.screenId}
/>
)}
{/* 다국어 설정 모달 */}
<MultilangSettingsModal
isOpen={showMultilangSettingsModal}
onClose={() => setShowMultilangSettingsModal(false)}
components={layout.components}
onSave={async (updates) => {
if (updates.length === 0) {
toast.info("저장할 변경사항이 없습니다.");
return;
}
try {
// 공통 유틸 사용하여 매핑 적용
const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor");
// 매핑 형식 변환
const mappings = updates.map((u) => ({
componentId: u.componentId,
keyId: u.langKeyId,
langKey: u.langKey,
}));
// 레이아웃 업데이트
const updatedComponents = applyMultilangMappings(layout.components, mappings);
setLayout((prev) => ({
...prev,
components: updatedComponents,
}));
toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`);
} catch (error) {
console.error("다국어 설정 저장 실패:", error);
toast.error("다국어 설정 저장 중 오류가 발생했습니다.");
}
}}
/>
</div>
</TableOptionsProvider>
</ScreenPreviewProvider>

View File

@ -0,0 +1,477 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown, Folder } from "lucide-react";
import { cn } from "@/lib/utils";
interface ScreenGroupModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
group?: ScreenGroup | null; // 수정 모드일 때 기존 그룹 데이터
}
export function ScreenGroupModal({
isOpen,
onClose,
onSuccess,
group,
}: ScreenGroupModalProps) {
const [currentCompanyCode, setCurrentCompanyCode] = useState<string>("");
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
const [formData, setFormData] = useState({
group_name: "",
group_code: "",
description: "",
display_order: 0,
target_company_code: "",
parent_group_id: null as number | null,
});
const [loading, setLoading] = useState(false);
const [companies, setCompanies] = useState<{ code: string; name: string }[]>([]);
const [availableParentGroups, setAvailableParentGroups] = useState<ScreenGroup[]>([]);
const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false);
// 그룹 경로 가져오기 (계층 구조 표시용)
const getGroupPath = (groupId: number): string => {
const grp = availableParentGroups.find((g) => g.id === groupId);
if (!grp) return "";
const path: string[] = [grp.group_name];
let currentGroup = grp;
while ((currentGroup as any).parent_group_id) {
const parent = availableParentGroups.find((g) => g.id === (currentGroup as any).parent_group_id);
if (parent) {
path.unshift(parent.group_name);
currentGroup = parent;
} else {
break;
}
}
return path.join(" > ");
};
// 그룹을 계층 구조로 정렬
const getSortedGroups = (): typeof availableParentGroups => {
const result: typeof availableParentGroups = [];
const addChildren = (parentId: number | null, level: number) => {
const children = availableParentGroups
.filter((g) => (g as any).parent_group_id === parentId)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
for (const child of children) {
result.push({ ...child, group_level: level } as any);
addChildren(child.id, level + 1);
}
};
addChildren(null, 1);
return result;
};
// 현재 사용자 정보 로드
useEffect(() => {
const loadUserInfo = async () => {
try {
const response = await apiClient.get("/auth/me");
const result = response.data;
if (result.success && result.data) {
const companyCode = result.data.companyCode || result.data.company_code || "";
setCurrentCompanyCode(companyCode);
setIsSuperAdmin(companyCode === "*");
}
} catch (error) {
console.error("사용자 정보 로드 실패:", error);
}
};
if (isOpen) {
loadUserInfo();
}
}, [isOpen]);
// 회사 목록 로드 (최고 관리자만)
useEffect(() => {
if (isSuperAdmin && isOpen) {
const loadCompanies = async () => {
try {
const response = await apiClient.get("/admin/companies");
const result = response.data;
if (result.success && result.data) {
const companyList = result.data.map((c: any) => ({
code: c.company_code,
name: c.company_name,
}));
setCompanies(companyList);
}
} catch (error) {
console.error("회사 목록 로드 실패:", error);
}
};
loadCompanies();
}
}, [isSuperAdmin, isOpen]);
// 부모 그룹 목록 로드 (현재 회사의 대분류/중분류 그룹만)
useEffect(() => {
if (isOpen && currentCompanyCode) {
const loadParentGroups = async () => {
try {
const response = await apiClient.get(`/screen-groups/groups?size=1000`);
const result = response.data;
if (result.success && result.data) {
// 모든 그룹을 상위 그룹으로 선택 가능 (무한 중첩 지원)
setAvailableParentGroups(result.data);
}
} catch (error) {
console.error("부모 그룹 목록 로드 실패:", error);
}
};
loadParentGroups();
}
}, [isOpen, currentCompanyCode]);
// 그룹 데이터가 변경되면 폼 초기화
useEffect(() => {
if (currentCompanyCode) {
if (group) {
setFormData({
group_name: group.group_name || "",
group_code: group.group_code || "",
description: group.description || "",
display_order: group.display_order || 0,
target_company_code: group.company_code || currentCompanyCode,
parent_group_id: (group as any).parent_group_id || null,
});
} else {
setFormData({
group_name: "",
group_code: "",
description: "",
display_order: 0,
target_company_code: currentCompanyCode,
parent_group_id: null,
});
}
}
}, [group, isOpen, currentCompanyCode]);
const handleSubmit = async () => {
// 필수 필드 검증
if (!formData.group_name.trim()) {
toast.error("그룹명을 입력하세요");
return;
}
if (!formData.group_code.trim()) {
toast.error("그룹 코드를 입력하세요");
return;
}
setLoading(true);
try {
let response;
if (group) {
// 수정 모드
response = await updateScreenGroup(group.id, formData);
} else {
// 추가 모드
response = await createScreenGroup({
...formData,
is_active: "Y",
});
}
if (response.success) {
toast.success(group ? "그룹이 수정되었습니다" : "그룹이 추가되었습니다");
onSuccess();
onClose();
} else {
toast.error(response.message || "작업에 실패했습니다");
}
} catch (error: any) {
console.error("그룹 저장 실패:", error);
toast.error("그룹 저장에 실패했습니다");
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{group ? "그룹 수정" : "그룹 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 회사 선택 (최고 관리자만) */}
{isSuperAdmin && (
<div>
<Label htmlFor="target_company_code" className="text-xs sm:text-sm">
*
</Label>
<Select
value={formData.target_company_code}
onValueChange={(value) =>
setFormData({ ...formData, target_company_code: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="회사를 선택하세요" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name} ({company.code})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
)}
{/* 부모 그룹 선택 (하위 그룹 만들기) - 트리 구조 + 검색 */}
<div>
<Label htmlFor="parent_group_id" className="text-xs sm:text-sm">
()
</Label>
<Popover open={isParentGroupSelectOpen} onOpenChange={setIsParentGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isParentGroupSelectOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{formData.parent_group_id === null
? "대분류로 생성"
: getGroupPath(formData.parent_group_id) || "그룹 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 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 sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-2 text-center">
</CommandEmpty>
<CommandGroup>
{/* 대분류로 생성 옵션 */}
<CommandItem
value="none"
onSelect={() => {
setFormData({
...formData,
parent_group_id: null,
// 대분류 선택 시 현재 회사 코드 유지
});
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parent_group_id === null ? "opacity-100" : "opacity-0"
)}
/>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
</CommandItem>
{/* 계층 구조로 그룹 표시 */}
{getSortedGroups().map((parentGroup) => (
<CommandItem
key={parentGroup.id}
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
onSelect={() => {
// 상위 그룹의 company_code로 자동 설정
const parentCompanyCode = parentGroup.company_code || formData.target_company_code;
setFormData({
...formData,
parent_group_id: parentGroup.id,
target_company_code: parentCompanyCode,
});
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parent_group_id === parentGroup.id ? "opacity-100" : "opacity-0"
)}
/>
{/* 들여쓰기로 계층 표시 */}
<span
style={{ marginLeft: `${(((parentGroup as any).group_level || 1) - 1) * 16}px` }}
className="flex items-center"
>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
{parentGroup.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 그룹명 */}
<div>
<Label htmlFor="group_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_name"
value={formData.group_name}
onChange={(e) =>
setFormData({ ...formData, group_name: e.target.value })
}
placeholder="그룹명을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 그룹 코드 */}
<div>
<Label htmlFor="group_code" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_code"
value={formData.group_code}
onChange={(e) =>
setFormData({ ...formData, group_code: e.target.value })
}
placeholder="영문 대문자와 언더스코어로 입력"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={!!group} // 수정 모드일 때는 코드 변경 불가
/>
{group && (
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
)}
</div>
{/* 설명 */}
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="그룹에 대한 설명을 입력하세요"
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
/>
</div>
{/* 정렬 순서 */}
<div>
<Label htmlFor="display_order" className="text-xs sm:text-sm">
</Label>
<Input
id="display_order"
type="number"
value={formData.display_order}
onChange={(e) =>
setFormData({
...formData,
display_order: parseInt(e.target.value) || 0,
})
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,8 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateC
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
import { Layers } from "lucide-react";
import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal";
import dynamic from "next/dynamic";
@ -93,6 +95,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 그룹 필터 관련 상태
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
const [groups, setGroups] = useState<ScreenGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 검색어 디바운스를 위한 타이머 ref
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
@ -183,6 +190,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
}
};
// 화면 그룹 목록 로드
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
try {
setLoadingGroups(true);
const response = await getScreenGroups();
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 조회 실패:", error);
} finally {
setLoadingGroups(false);
}
};
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
useEffect(() => {
// 이전 타이머 취소
@ -224,6 +250,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
params.companyCode = selectedCompanyCode;
}
// 그룹 필터
if (selectedGroupId !== "all") {
params.groupId = selectedGroupId;
}
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
const resp = await screenApi.getScreens(params);
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
@ -256,7 +287,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
return () => {
abort = true;
};
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
const filteredScreens = screens; // 서버 필터 기준 사용
@ -671,6 +702,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div>
)}
{/* 그룹 필터 */}
<div className="w-full sm:w-[180px]">
<Select value={selectedGroupId} onValueChange={setSelectedGroupId} disabled={activeTab === "trash"}>
<SelectTrigger className="h-10 text-sm">
<Layers className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="전체 그룹" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="ungrouped"></SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.groupName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 검색 입력 */}
<div className="w-full sm:w-[400px]">
<div className="relative">

View File

@ -0,0 +1,869 @@
"use client";
import React, { useMemo, useState, useEffect } from "react";
import { Handle, Position } from "@xyflow/react";
import {
Monitor,
Database,
FormInput,
Table2,
LayoutDashboard,
MousePointer2,
Key,
Link2,
Columns3,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
// ========== 타입 정의 ==========
// 화면 노드 데이터 인터페이스
export interface ScreenNodeData {
label: string;
subLabel?: string;
type: "screen" | "table" | "action";
tableName?: string;
isMain?: boolean;
// 레이아웃 요약 정보 (미리보기용)
layoutSummary?: ScreenLayoutSummary;
// 그룹 내 포커스 관련 속성
isInGroup?: boolean; // 그룹 모드인지
isFocused?: boolean; // 포커스된 화면인지
isFaded?: boolean; // 흑백 처리할지
screenRole?: string; // 화면 역할 (메인그리드, 등록폼 등)
}
// 필드 매핑 정보 (조인 관계 표시용)
export interface FieldMappingDisplay {
sourceField: string; // 메인 테이블 컬럼 (예: manager_id)
targetField: string; // 서브 테이블 컬럼 (예: user_id)
sourceDisplayName?: string; // 메인 테이블 한글 컬럼명 (예: 담당자)
targetDisplayName?: string; // 서브 테이블 한글 컬럼명 (예: 사용자ID)
sourceTable?: string; // 소스 테이블명 (필드 매핑에서 테이블 구분용)
}
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
export interface ReferenceInfo {
fromTable: string; // 참조하는 테이블명 (영문)
fromTableLabel?: string; // 참조하는 테이블 한글명
fromColumn: string; // 참조하는 컬럼명 (영문)
fromColumnLabel?: string; // 참조하는 컬럼 한글명
toColumn: string; // 참조되는 컬럼명 (이 테이블의 컬럼)
toColumnLabel?: string; // 참조되는 컬럼 한글명
relationType: 'lookup' | 'join' | 'filter'; // 참조 유형
}
// 테이블 노드 데이터 인터페이스
export interface TableNodeData {
label: string;
subLabel?: string;
isMain?: boolean;
isFocused?: boolean; // 포커스된 테이블인지
isFaded?: boolean; // 흑백 처리할지
columns?: Array<{
name: string; // 표시용 이름 (한글명)
originalName?: string; // 원본 컬럼명 (영문, 필터링용)
type: string;
isPrimaryKey?: boolean;
isForeignKey?: boolean;
}>;
// 포커스 시 강조할 컬럼 정보
highlightedColumns?: string[]; // 화면에서 사용하는 컬럼 (영문명)
joinColumns?: string[]; // 조인에 사용되는 컬럼
joinColumnRefs?: Array<{ // 조인 컬럼의 참조 정보
column: string; // FK 컬럼명 (예: 'customer_id')
refTable: string; // 참조 테이블 (예: 'customer_mng')
refTableLabel?: string; // 참조 테이블 한글명 (예: '거래처 관리')
refColumn: string; // 참조 컬럼 (예: 'customer_code')
}>;
filterColumns?: string[]; // 필터링에 사용되는 FK 컬럼 (마스터-디테일 관계)
// 필드 매핑 정보 (조인 관계 표시용)
fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
referencedBy?: ReferenceInfo[]; // 이 테이블을 참조하는 관계들
// 저장 관계 정보
saveInfos?: Array<{
saveType: string; // 'save' | 'edit' | 'delete' | 'transferData'
componentType: string; // 버튼 컴포넌트 타입
isMainTable: boolean; // 메인 테이블 저장인지
sourceScreenId?: number; // 어떤 화면에서 저장하는지
}>;
}
// ========== 유틸리티 함수 ==========
// 화면 타입별 아이콘
const getScreenTypeIcon = (screenType?: string) => {
switch (screenType) {
case "grid":
return <Table2 className="h-4 w-4" />;
case "dashboard":
return <LayoutDashboard className="h-4 w-4" />;
case "action":
return <MousePointer2 className="h-4 w-4" />;
default:
return <FormInput className="h-4 w-4" />;
}
};
// 화면 타입별 색상 (헤더)
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
if (!isMain) return "bg-slate-400";
switch (screenType) {
case "grid":
return "bg-violet-500";
case "dashboard":
return "bg-amber-500";
case "action":
return "bg-rose-500";
default:
return "bg-blue-500";
}
};
// 화면 역할(screenRole)에 따른 색상
const getScreenRoleColor = (screenRole?: string) => {
if (!screenRole) return "bg-slate-400";
// 역할명에 포함된 키워드로 색상 결정
const role = screenRole.toLowerCase();
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
return "bg-violet-500"; // 보라색 - 메인 그리드
}
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
return "bg-blue-500"; // 파란색 - 등록 폼
}
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
return "bg-rose-500"; // 빨간색 - 액션/이벤트
}
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
return "bg-amber-500"; // 주황색 - 상세/팝업
}
return "bg-slate-400"; // 기본 회색
};
// 화면 타입별 라벨
const getScreenTypeLabel = (screenType?: string) => {
switch (screenType) {
case "grid":
return "그리드";
case "dashboard":
return "대시보드";
case "action":
return "액션";
default:
return "폼";
}
};
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
const screenType = layoutSummary?.screenType || "form";
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
// isFocused일 때 색상 활성화, isFaded일 때 회색
let headerColor: string;
if (isInGroup) {
if (isFaded) {
headerColor = "bg-gray-300"; // 흑백 처리 - 더 확실한 회색
} else {
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
headerColor = getScreenRoleColor(screenRole);
}
} else {
headerColor = getScreenTypeColor(screenType, isMain);
}
return (
<div
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
isFocused
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
: isFaded
? "border-gray-200 opacity-50"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
}`}
style={{
filter: isFaded ? "grayscale(100%)" : "none",
transition: "all 0.3s ease",
transform: isFocused ? "scale(1.02)" : "scale(1)",
}}
>
{/* Handles */}
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
<div className="h-[140px] overflow-hidden bg-slate-50 p-2">
{layoutSummary ? (
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
{getScreenTypeIcon(screenType)}
<span className="mt-1 text-[10px]">: {label}</span>
</div>
)}
</div>
{/* 필드 매핑 영역 */}
<div className="flex-1 overflow-hidden border-t border-slate-200 bg-white px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-slate-500">
<Columns3 className="h-3 w-3" />
<span> </span>
<span className="ml-auto text-[8px] text-slate-400">
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
{layoutSummary?.layoutItems
?.filter(item => item.label && !item.componentKind?.includes('button'))
?.slice(0, 6)
?.map((item, idx) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Database className="h-3 w-3" />
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
{getScreenTypeLabel(screenType)}
</span>
</div>
</div>
);
};
// ========== 컴포넌트 종류별 미니어처 색상 ==========
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등)
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
return "bg-violet-200 border-violet-400";
}
// 검색 필터
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
return "bg-pink-200 border-pink-400";
}
// 버튼 관련
if (componentKind?.includes("button")) {
return "bg-blue-300 border-blue-500";
}
// 입력 필드
if (componentKind?.includes("input") || componentKind?.includes("text")) {
return "bg-slate-200 border-slate-400";
}
// 셀렉트/드롭다운
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
return "bg-amber-200 border-amber-400";
}
// 차트
if (componentKind?.includes("chart")) {
return "bg-emerald-200 border-emerald-400";
}
// 커스텀 위젯
if (componentKind === "custom") {
return "bg-pink-200 border-pink-400";
}
return "bg-slate-100 border-slate-300";
};
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({
layoutSummary,
screenType,
}) => {
const { totalComponents, widgetCounts } = layoutSummary;
// 그리드 화면 일러스트
if (screenType === "grid") {
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
))}
</div>
{/* 테이블 행들 */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{[...Array(7)].map((_, i) => (
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-slate-100" : "bg-white"}`}>
{[...Array(5)].map((_, j) => (
<div key={j} className="h-2 flex-1 rounded bg-slate-300/70" />
))}
</div>
))}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-2 pt-1">
<div className="h-2.5 w-4 rounded bg-slate-300" />
<div className="h-2.5 w-4 rounded bg-blue-500" />
<div className="h-2.5 w-4 rounded bg-slate-300" />
<div className="h-2.5 w-4 rounded bg-slate-300" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 폼 화면 일러스트
if (screenType === "form") {
return (
<div className="flex h-full flex-col gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 폼 필드들 */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-2.5 w-14 rounded bg-slate-400" />
<div className="h-5 flex-1 rounded-md border border-slate-300 bg-white shadow-sm" />
</div>
))}
{/* 버튼 영역 */}
<div className="mt-auto flex justify-end gap-2 border-t border-slate-100 pt-3">
<div className="h-5 w-14 rounded-md bg-slate-300 shadow-sm" />
<div className="h-5 w-14 rounded-md bg-blue-500 shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 대시보드 화면 일러스트
if (screenType === "dashboard") {
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
<div className="col-span-2 rounded-lg bg-blue-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-blue-400" />
<div className="flex h-14 items-end gap-1">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-blue-400/80"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 액션 화면 일러스트 (버튼 중심)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-blue-500 shadow-sm" />
<div className="h-7 w-16 rounded-md bg-slate-300 shadow-sm" />
</div>
<div className="text-xs font-medium text-slate-400"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 기본 (알 수 없는 타입)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
</div>
);
};
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data;
// 강조할 컬럼 세트 (영문 컬럼명 기준)
const highlightSet = new Set(highlightedColumns || []);
const filterSet = new Set(filterColumns || []); // 필터링에 사용되는 FK 컬럼
const joinSet = new Set(joinColumns || []);
// 조인 컬럼 참조 정보 맵 생성 (column → { refTable, refTableLabel, refColumn })
const joinRefMap = new Map<string, { refTable: string; refTableLabel: string; refColumn: string }>();
if (joinColumnRefs) {
joinColumnRefs.forEach((ref) => {
joinRefMap.set(ref.column, {
refTable: ref.refTable,
refTableLabel: ref.refTableLabel || ref.refTable, // 한글명 (없으면 영문명)
refColumn: ref.refColumn
});
});
}
// 필드 매핑 맵 생성 (targetField → { sourceField, sourceDisplayName })
// 서브 테이블에서 targetField가 어떤 메인 테이블 컬럼(sourceField)과 연결되는지
const fieldMappingMap = new Map<string, { sourceField: string; sourceDisplayName: string }>();
if (fieldMappings) {
fieldMappings.forEach(mapping => {
fieldMappingMap.set(mapping.targetField, {
sourceField: mapping.sourceField,
// 한글명이 있으면 한글명, 없으면 영문명 사용
sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField,
});
});
}
// 필터 소스 컬럼 세트 (메인 테이블에서 필터에 사용되는 컬럼)
const filterSourceSet = new Set(
referencedBy?.filter(r => r.relationType === 'filter').map(r => r.fromColumn) || []
);
// 포커스 모드: 사용 컬럼만 필터링하여 표시
// originalName (영문) 또는 name으로 매칭 시도
// 필터 컬럼(filterSet) 및 필터 소스 컬럼(filterSourceSet)도 포함하여 보라색으로 표시
const potentialFilteredColumns = columns?.filter(col => {
const colOriginal = col.originalName || col.name;
return highlightSet.has(colOriginal) || joinSet.has(colOriginal) || filterSet.has(colOriginal) || filterSourceSet.has(colOriginal);
}) || [];
// 정렬: 조인 컬럼 → 필터 컬럼/필터 소스 컬럼 → 사용 컬럼 순서
const sortedFilteredColumns = [...potentialFilteredColumns].sort((a, b) => {
const aOriginal = a.originalName || a.name;
const bOriginal = b.originalName || b.name;
const aIsJoin = joinSet.has(aOriginal);
const bIsJoin = joinSet.has(bOriginal);
const aIsFilter = filterSet.has(aOriginal) || filterSourceSet.has(aOriginal);
const bIsFilter = filterSet.has(bOriginal) || filterSourceSet.has(bOriginal);
// 조인 컬럼 우선
if (aIsJoin && !bIsJoin) return -1;
if (!aIsJoin && bIsJoin) return 1;
// 필터 컬럼/필터 소스 다음
if (aIsFilter && !bIsFilter) return -1;
if (!aIsFilter && bIsFilter) return 1;
// 나머지는 원래 순서 유지
return 0;
});
const hasActiveColumns = sortedFilteredColumns.length > 0;
// 필터 관계가 있는 테이블인지 확인 (마스터-디테일 필터링)
// - hasFilterRelation: 디테일 테이블 (WHERE 조건 대상) - filterColumns에 FK 컬럼이 있음
// - isFilterSource: 마스터 테이블 (필터 소스, WHERE 조건 제공) - 포커스된 화면의 메인 테이블이고 filterSourceSet에 컬럼이 있음
// 디테일 테이블: filterColumns(filterSet)에 FK 컬럼이 있고, 포커스된 화면의 메인이 아님
const hasFilterRelation = filterSet.size > 0 && !isFocused;
// 마스터 테이블: 포커스된 화면의 메인 테이블(isFocused)이고 filterSourceSet에 컬럼이 있음
const isFilterSource = isFocused && filterSourceSet.size > 0;
// 표시할 컬럼:
// - 포커스 시 (활성 컬럼 있음): 정렬된 컬럼만 표시
// - 비포커스 시: 최대 8개만 표시
const MAX_DEFAULT_COLUMNS = 8;
const allColumns = columns || [];
const displayColumns = hasActiveColumns
? sortedFilteredColumns
: allColumns.slice(0, MAX_DEFAULT_COLUMNS);
const remainingCount = hasActiveColumns
? 0
: Math.max(0, allColumns.length - MAX_DEFAULT_COLUMNS);
const totalCount = allColumns.length;
// 컬럼 수 기반 높이 계산 (DOM 측정 없이)
// - 각 컬럼 행 높이: 약 22px (py-0.5 + text + gap-px)
// - 컨테이너 패딩: p-1.5 = 12px (상하 합계)
// - 뱃지 높이: 약 26px (py-1 + text + gap)
const COLUMN_ROW_HEIGHT = 22;
const CONTAINER_PADDING = 12;
const BADGE_HEIGHT = 26;
const MAX_HEIGHT = 200; // 뱃지 포함 가능하도록 증가
// 뱃지가 표시될지 미리 계산 (필터/참조만, 저장은 헤더에 표시)
const hasFilterOrLookupBadge = referencedBy && referencedBy.some(r => r.relationType === 'filter' || r.relationType === 'lookup');
const hasBadge = hasFilterOrLookupBadge;
const calculatedHeight = useMemo(() => {
const badgeHeight = hasBadge ? BADGE_HEIGHT : 0;
const rawHeight = CONTAINER_PADDING + badgeHeight + (displayColumns.length * COLUMN_ROW_HEIGHT);
return Math.min(rawHeight, MAX_HEIGHT);
}, [displayColumns.length, hasBadge]);
// Debounce된 높이: 중간 값(늘어났다가 줄어드는 현상)을 무시하고 최종 값만 사용
// 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결
const [debouncedHeight, setDebouncedHeight] = useState(calculatedHeight);
useEffect(() => {
// 50ms 내에 다시 변경되면 이전 값 무시
const timer = setTimeout(() => {
setDebouncedHeight(calculatedHeight);
}, 50);
return () => clearTimeout(timer);
}, [calculatedHeight]);
// 저장 대상 여부
const hasSaveTarget = saveInfos && saveInfos.length > 0;
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
(hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 순수 포커스 (필터 관계 없음): 초록색
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
// 흐리게 처리
: isFaded
? "border-gray-200 opacity-60 bg-card"
// 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
// 색상/테두리/그림자만 transition (높이 제외)
transition: "background-color 0.7s ease, border-color 0.7s ease, box-shadow 0.7s ease, filter 0.3s ease, opacity 0.3s ease",
}}
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
>
{/* 저장 대상: 테이블 바깥 왼쪽에 띄워진 막대기 (나타나기/사라지기 애니메이션) */}
<div
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
style={{
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
opacity: hasSaveTarget ? 1 : 0,
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'top',
pointerEvents: hasSaveTarget ? 'auto' : 'none',
}}
/>
{/* Handles */}
{/* top target: 화면 → 메인테이블 연결용 */}
<Handle
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
type="source"
position={Position.Top}
id="top_source"
style={{ top: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
type="target"
position={Position.Bottom}
id="bottom_target"
style={{ bottom: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
isFaded ? "bg-gray-400" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate text-[11px] font-semibold">{label}</div>
{/* 필터 관계에 따른 문구 변경 */}
<div className="truncate text-[9px] opacity-80">
{isFilterSource
? "마스터 테이블 (필터 소스)"
: hasFilterRelation
? "디테일 테이블 (WHERE 조건)"
: subLabel}
</div>
</div>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
{displayColumns.length}
</span>
)}
</div>
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
style={{
height: `${debouncedHeight}px`,
maxHeight: `${MAX_HEIGHT}px`,
// Debounce로 중간 값이 무시되므로 항상 부드러운 transition 적용 가능
transition: 'height 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{/* 필터링/참조 관계 뱃지 (컬럼 목록 영역 안에 포함, 저장은 헤더에 표시) */}
{hasBadge && (() => {
const filterRefs = referencedBy?.filter(r => r.relationType === 'filter') || [];
const lookupRefs = referencedBy?.filter(r => r.relationType === 'lookup') || [];
if (filterRefs.length === 0 && lookupRefs.length === 0) return null;
return (
<div className="flex items-center gap-1.5 px-2 py-1 mb-1.5 rounded border border-slate-300 bg-slate-50 text-[9px]">
{/* 필터 뱃지 */}
{filterRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-3 w-3" />
<span></span>
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-700 font-medium truncate">
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{/* 참조 뱃지 */}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable}${r.toColumn}`).join('\n')}`}
>
{lookupRefs.length}
</span>
)}
</div>
);
})()}
{displayColumns.length > 0 ? (
<div className="flex flex-col gap-px transition-all duration-700 ease-in-out">
{displayColumns.map((col, idx) => {
const colOriginal = col.originalName || col.name;
const isJoinColumn = joinSet.has(colOriginal);
const isFilterColumn = filterSet.has(colOriginal); // 서브 테이블의 필터링 FK 컬럼
const isHighlighted = highlightSet.has(colOriginal);
// 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지) - 서브 테이블용
const filterRefInfo = referencedBy?.find(
r => r.relationType === 'filter' && r.toColumn === colOriginal
);
// 메인 테이블에서 필터 소스로 사용되는 컬럼인지 (fromColumn과 일치)
const isFilterSourceColumn = filterSourceSet.has(colOriginal);
return (
<div
key={col.name}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
isJoinColumn
? "bg-orange-100 border border-orange-300 shadow-sm"
: isFilterColumn || isFilterSourceColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
: isHighlighted
? "bg-blue-100 border border-blue-300 shadow-sm"
: hasActiveColumns
? "bg-slate-100"
: "bg-slate-50 hover:bg-slate-100"
}`}
style={{
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
opacity: hasActiveColumns ? 0 : 1,
}}
>
{/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-orange-500" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
: isHighlighted ? "text-blue-700"
: "text-slate-700"
}`}>
{col.name}
</span>
{/* 역할 태그 + 참조 관계 표시 */}
{isJoinColumn && (
<>
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
)}
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
</>
)}
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
)}
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}
{/* 타입 */}
<span className="text-[8px] text-slate-400">{col.type}</span>
</div>
);
})}
{/* 더 많은 컬럼이 있을 경우 표시 */}
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">
+ {remainingCount}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
</div>
)}
</div>
{/* 푸터 (컴팩트) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[9px] text-muted-foreground">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}
</span>
)}
</div>
{/* CSS 애니메이션 정의 */}
<style jsx>{`
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
};
// ========== 기존 호환성 유지용 ==========
export const LegacyScreenNode = ScreenNode;
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
return (
<div className="rounded-lg border-2 border-purple-300 bg-white p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,296 @@
"use client";
import { useState, useEffect } from "react";
import { ScreenDefinition } from "@/types/screen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Database,
Monitor,
ArrowRight,
Link2,
Table,
Columns,
ExternalLink,
Layers,
GitBranch
} from "lucide-react";
import { getFieldJoins, getDataFlows, getTableRelations, FieldJoin, DataFlow, TableRelation } from "@/lib/api/screenGroup";
import { screenApi } from "@/lib/api/screen";
interface ScreenRelationViewProps {
screen: ScreenDefinition | null;
}
export function ScreenRelationView({ screen }: ScreenRelationViewProps) {
const [loading, setLoading] = useState(false);
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [tableRelations, setTableRelations] = useState<TableRelation[]>([]);
const [layoutInfo, setLayoutInfo] = useState<any>(null);
useEffect(() => {
const loadRelations = async () => {
if (!screen) {
setFieldJoins([]);
setDataFlows([]);
setTableRelations([]);
setLayoutInfo(null);
return;
}
try {
setLoading(true);
// 병렬로 데이터 로드
const [joinsRes, flowsRes, relationsRes, layoutRes] = await Promise.all([
getFieldJoins(screen.screenId),
getDataFlows(screen.screenId),
getTableRelations(screen.screenId),
screenApi.getLayout(screen.screenId).catch(() => null),
]);
if (joinsRes.success && joinsRes.data) {
setFieldJoins(joinsRes.data);
}
if (flowsRes.success && flowsRes.data) {
setDataFlows(flowsRes.data);
}
if (relationsRes.success && relationsRes.data) {
setTableRelations(relationsRes.data);
}
if (layoutRes) {
setLayoutInfo(layoutRes);
}
} catch (error) {
console.error("관계 정보 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadRelations();
}, [screen?.screenId]);
if (!screen) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Layers className="h-16 w-16 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground/70">
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
);
}
// 컴포넌트에서 사용하는 테이블 분석
const getUsedTables = () => {
const tables = new Set<string>();
if (screen.tableName) {
tables.add(screen.tableName);
}
if (layoutInfo?.components) {
layoutInfo.components.forEach((comp: any) => {
if (comp.properties?.tableName) {
tables.add(comp.properties.tableName);
}
if (comp.properties?.dataSource?.tableName) {
tables.add(comp.properties.dataSource.tableName);
}
});
}
return Array.from(tables);
};
const usedTables = getUsedTables();
return (
<div className="p-4 space-y-4 overflow-auto h-full">
{/* 화면 기본 정보 */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10">
<Monitor className="h-6 w-6 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{screen.screenName}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline">{screen.screenCode}</Badge>
<Badge variant="secondary">{screen.screenType}</Badge>
</div>
{screen.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{screen.description}
</p>
)}
</div>
</div>
<Separator />
{/* 연결된 테이블 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-green-500" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{usedTables.length > 0 ? (
<div className="space-y-2">
{usedTables.map((tableName, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Table className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-mono">{tableName}</span>
{tableName === screen.tableName && (
<Badge variant="default" className="text-xs ml-auto">
</Badge>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 필드 조인 관계 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Link2 className="h-4 w-4 text-purple-500" />
{fieldJoins.length > 0 && (
<Badge variant="secondary" className="ml-auto">{fieldJoins.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{fieldJoins.length > 0 ? (
<div className="space-y-2">
{fieldJoins.map((join) => (
<div
key={join.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<div className="flex items-center gap-1">
<span className="font-mono text-xs">{join.sourceTable}</span>
<span className="text-muted-foreground">.</span>
<span className="font-mono text-xs text-blue-600">{join.sourceColumn}</span>
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="flex items-center gap-1">
<span className="font-mono text-xs">{join.targetTable}</span>
<span className="text-muted-foreground">.</span>
<span className="font-mono text-xs text-green-600">{join.targetColumn}</span>
</div>
<Badge variant="outline" className="ml-auto text-xs">
{join.joinType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 데이터 흐름 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<GitBranch className="h-4 w-4 text-orange-500" />
{dataFlows.length > 0 && (
<Badge variant="secondary" className="ml-auto">{dataFlows.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{dataFlows.length > 0 ? (
<div className="space-y-2">
{dataFlows.map((flow) => (
<div
key={flow.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<Monitor className="h-4 w-4 text-blue-500" />
<span className="truncate">{flow.flowName || "이름 없음"}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Monitor className="h-4 w-4 text-green-500" />
<span className="text-muted-foreground truncate">
#{flow.targetScreenId}
</span>
<Badge variant="outline" className="ml-auto text-xs">
{flow.flowType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 테이블 관계 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Columns className="h-4 w-4 text-cyan-500" />
{tableRelations.length > 0 && (
<Badge variant="secondary" className="ml-auto">{tableRelations.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{tableRelations.length > 0 ? (
<div className="space-y-2">
{tableRelations.map((relation) => (
<div
key={relation.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<Table className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">{relation.parentTable}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs">{relation.childTable}</span>
<Badge variant="outline" className="ml-auto text-xs">
{relation.relationType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 빠른 작업 */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground mb-2">
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps {
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
*
*/
interface FilterItemCollapsibleProps {
filter: ColumnFilter;
index: number;
filterSummary: string;
onRemove: () => void;
children: React.ReactNode;
}
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
filter,
index,
filterSummary,
onRemove,
children,
}) => {
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-lg border p-2">
<CollapsibleTrigger asChild>
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
{/* 상단: 필터 번호 + 삭제 버튼 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{isOpen ? (
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
)}
<span className="text-muted-foreground text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 shrink-0 p-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 하단: 필터 요약 (전체 너비 사용) */}
<div className="mt-1 pl-4">
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
{filterSummary}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
</div>
</Collapsible>
);
};
/**
*
* , ,
@ -42,7 +104,7 @@ export function DataFilterConfigPanel({
enabled: false,
filters: [],
matchType: "all",
}
},
);
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
@ -69,7 +131,7 @@ export function DataFilterConfigPanel({
return; // 이미 로드되었거나 로딩 중이면 스킵
}
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
try {
console.log("🔍 카테고리 값 로드 시작:", {
@ -82,7 +144,7 @@ export function DataFilterConfigPanel({
tableName,
columnName,
false, // includeInactive
menuObjid // 🆕 메뉴 OBJID 전달
menuObjid, // 🆕 메뉴 OBJID 전달
);
console.log("📦 카테고리 값 로드 응답:", response);
@ -94,14 +156,14 @@ export function DataFilterConfigPanel({
}));
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
} else {
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
}
} catch (error) {
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
} finally {
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
}
};
@ -145,9 +207,7 @@ export function DataFilterConfigPanel({
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((filter) =>
filter.id === filterId ? { ...filter, [field]: value } : filter
),
filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
@ -178,7 +238,7 @@ export function DataFilterConfigPanel({
<>
{/* 테이블명 표시 */}
{tableName && (
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
: <span className="font-medium">{tableName}</span>
</div>
)}
@ -200,235 +260,127 @@ export function DataFilterConfigPanel({
)}
{/* 필터 목록 */}
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => (
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleRemoveFilter(filter.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="max-h-[600px] space-y-2 overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => {
// 연산자 표시 텍스트
const operatorLabels: Record<string, string> = {
equals: "=",
not_equals: "!=",
greater_than: ">",
less_than: "<",
greater_than_or_equal: ">=",
less_than_or_equal: "<=",
between: "BETWEEN",
in: "IN",
not_in: "NOT IN",
contains: "LIKE",
starts_with: "시작",
ends_with: "끝",
is_null: "IS NULL",
is_not_null: "IS NOT NULL",
date_range_contains: "기간 내",
};
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
{filter.operator !== "date_range_contains" && (
<div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
// 컬럼 라벨 찾기
const columnLabel =
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName;
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 필터 요약 텍스트 생성
const filterSummary = filter.columnName
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value
? ` ${filter.value}`
: ""
}`
: "설정 필요";
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => {
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
if (value === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
handleFilterChange(filter.id, "operator", value);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="greater_than"> (&gt;)</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem>
<SelectItem value="greater_than_or_equal"> ()</SelectItem>
<SelectItem value="less_than_or_equal"> ()</SelectItem>
<SelectItem value="between"> (BETWEEN)</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
{filter.operator === "date_range_contains" && (
<>
<div className="col-span-2">
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
💡 :
<br /> NULL
<br /> NULL
<br />
</p>
</div>
return (
<FilterItemCollapsible
key={filter.id}
filter={filter}
index={index}
filterSummary={filterSummary}
onRemove={() => handleRemoveFilter(filter.id)}
>
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
{filter.operator !== "date_range_contains" && (
<div>
<Label className="text-xs"> </Label>
<Label className="text-xs"></Label>
<Select
value={filter.rangeConfig?.startColumn || ""}
value={filter.columnName}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: value,
endColumn: filter.rangeConfig?.endColumn || "",
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
const column = columns.find((col) => col.columnName === value);
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.valueType}
onValueChange={(value: any) => {
// dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "dynamic" && filter.operator === "date_range_contains") {
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "TODAY" }
: f
f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => {
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
if (value === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
handleFilterChange(filter.id, "operator", value);
}
}}
>
@ -436,106 +388,240 @@ export function DataFilterConfigPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
{filter.operator === "date_range_contains" && (
<SelectItem value="dynamic"> ( )</SelectItem>
)}
{isCategoryOrCodeColumn(filter.columnName) && (
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="greater_than"> (&gt;)</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem>
<SelectItem value="greater_than_or_equal"> ()</SelectItem>
<SelectItem value="less_than_or_equal"> ()</SelectItem>
<SelectItem value="between"> (BETWEEN)</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
{filter.operator !== "is_null" &&
filter.operator !== "is_not_null" &&
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
{filter.operator === "date_range_contains" && (
<>
<div className="col-span-2">
<p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs">
💡 :
<br /> NULL
<br /> NULL
<br />
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.startColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: value,
endColumn: filter.rangeConfig?.endColumn || "",
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter(
(col) =>
col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter(
(col) =>
col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
value={filter.valueType}
onValueChange={(value: any) => {
// dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "dynamic" && filter.operator === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
} />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categoryValues[filter.columnName].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
<SelectItem value="static"> </SelectItem>
{filter.operator === "date_range_contains" && (
<SelectItem value="dynamic"> ( )</SelectItem>
)}
{isCategoryOrCodeColumn(filter.columnName) && (
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
const values = e.target.value.split(",").map((v) => v.trim());
handleFilterChange(filter.id, "value", values);
}}
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : filter.operator === "between" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
}}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
: filter.operator === "date_range_contains"
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
</div>
)}
{/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700">
.
</p>
</div>
)}
</div>
))}
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
{filter.operator !== "is_null" &&
filter.operator !== "is_not_null" &&
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"}
/>
</SelectTrigger>
<SelectContent>
{categoryValues[filter.columnName].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
const values = e.target.value.split(",").map((v) => v.trim());
handleFilterChange(filter.id, "value", values);
}}
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : filter.operator === "between" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(
filter.id,
"value",
values.length === 2 ? values : [values[0] || "", ""],
);
}}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder={
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
: filter.operator === "date_range_contains"
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
{/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700"> .</p>
</div>
)}
</FilterItemCollapsible>
);
})}
</div>
{/* 필터 추가 버튼 */}
<Button
variant="outline"
size="sm"
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
className="h-8 w-full text-xs sm:h-10 sm:text-sm"
onClick={handleAddFilter}
disabled={columns.length === 0}
>
@ -544,13 +630,10 @@ export function DataFilterConfigPanel({
</Button>
{columns.length === 0 && (
<p className="text-xs text-muted-foreground text-center">
</p>
<p className="text-muted-foreground text-center text-xs"> </p>
)}
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,465 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react";
import {
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
DataFlow,
} from "@/lib/api/screenGroup";
interface DataFlowPanelProps {
groupId?: number;
screenId?: number;
screens?: Array<{ screen_id: number; screen_name: string }>;
}
export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) {
// 상태 관리
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<DataFlow | null>(null);
const [formData, setFormData] = useState({
source_screen_id: 0,
source_action: "",
target_screen_id: 0,
target_action: "",
data_mapping: "",
flow_type: "unidirectional",
flow_label: "",
condition_expression: "",
is_active: "Y",
});
// 데이터 로드
const loadDataFlows = useCallback(async () => {
setLoading(true);
try {
const response = await getDataFlows(groupId);
if (response.success && response.data) {
setDataFlows(response.data);
}
} catch (error) {
console.error("데이터 흐름 로드 실패:", error);
} finally {
setLoading(false);
}
}, [groupId]);
useEffect(() => {
loadDataFlows();
}, [loadDataFlows]);
// 모달 열기
const openModal = (flow?: DataFlow) => {
if (flow) {
setSelectedFlow(flow);
setFormData({
source_screen_id: flow.source_screen_id,
source_action: flow.source_action || "",
target_screen_id: flow.target_screen_id,
target_action: flow.target_action || "",
data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "",
flow_type: flow.flow_type,
flow_label: flow.flow_label || "",
condition_expression: flow.condition_expression || "",
is_active: flow.is_active,
});
} else {
setSelectedFlow(null);
setFormData({
source_screen_id: screenId || 0,
source_action: "",
target_screen_id: 0,
target_action: "",
data_mapping: "",
flow_type: "unidirectional",
flow_label: "",
condition_expression: "",
is_active: "Y",
});
}
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.source_screen_id || !formData.target_screen_id) {
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
return;
}
try {
let dataMappingJson = null;
if (formData.data_mapping) {
try {
dataMappingJson = JSON.parse(formData.data_mapping);
} catch {
toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다.");
return;
}
}
const payload = {
group_id: groupId,
source_screen_id: formData.source_screen_id,
source_action: formData.source_action || null,
target_screen_id: formData.target_screen_id,
target_action: formData.target_action || null,
data_mapping: dataMappingJson,
flow_type: formData.flow_type,
flow_label: formData.flow_label || null,
condition_expression: formData.condition_expression || null,
is_active: formData.is_active,
};
let response;
if (selectedFlow) {
response = await updateDataFlow(selectedFlow.id, payload);
} else {
response = await createDataFlow(payload);
}
if (response.success) {
toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
setIsModalOpen(false);
loadDataFlows();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return;
try {
const response = await deleteDataFlow(id);
if (response.success) {
toast.success("데이터 흐름이 삭제되었습니다.");
loadDataFlows();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
// 액션 옵션
const sourceActions = [
{ value: "click", label: "클릭" },
{ value: "submit", label: "제출" },
{ value: "select", label: "선택" },
{ value: "change", label: "변경" },
{ value: "doubleClick", label: "더블클릭" },
];
const targetActions = [
{ value: "open", label: "열기" },
{ value: "load", label: "로드" },
{ value: "refresh", label: "새로고침" },
{ value: "save", label: "저장" },
{ value: "filter", label: "필터" },
];
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={loadDataFlows} className="h-8 w-8 p-0">
<RefreshCw className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 설명 */}
<p className="text-xs text-muted-foreground">
. (: 목록 )
</p>
{/* 흐름 목록 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : dataFlows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
<GitBranch className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{dataFlows.map((flow) => (
<div
key={flow.id}
className="flex items-center justify-between rounded-lg border bg-card p-3 text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* 소스 화면 */}
<div className="flex flex-col">
<span className="font-medium truncate max-w-[100px]">
{flow.source_screen_name || `화면 ${flow.source_screen_id}`}
</span>
{flow.source_action && (
<span className="text-muted-foreground">{flow.source_action}</span>
)}
</div>
{/* 화살표 */}
<div className="flex items-center gap-1 text-primary">
<ArrowRight className="h-4 w-4" />
{flow.flow_type === "bidirectional" && (
<ArrowRight className="h-4 w-4 rotate-180" />
)}
</div>
{/* 타겟 화면 */}
<div className="flex flex-col">
<span className="font-medium truncate max-w-[100px]">
{flow.target_screen_name || `화면 ${flow.target_screen_id}`}
</span>
{flow.target_action && (
<span className="text-muted-foreground">{flow.target_action}</span>
)}
</div>
{/* 라벨 */}
{flow.flow_label && (
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">
{flow.flow_label}
</span>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(flow)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(flow.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 추가/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 소스 화면 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.source_screen_id.toString()}
onValueChange={(value) => setFormData({ ...formData, source_screen_id: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{screens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.source_action}
onValueChange={(value) => setFormData({ ...formData, source_action: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
{sourceActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 타겟 화면 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.target_screen_id.toString()}
onValueChange={(value) => setFormData({ ...formData, target_screen_id: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{screens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.target_action}
onValueChange={(value) => setFormData({ ...formData, target_action: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
{targetActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 흐름 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.flow_type}
onValueChange={(value) => setFormData({ ...formData, flow_type: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unidirectional"></SelectItem>
<SelectItem value="bidirectional"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.flow_label}
onChange={(e) => setFormData({ ...formData, flow_label: e.target.value })}
placeholder="예: 상세 보기"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 데이터 매핑 */}
<div>
<Label className="text-xs sm:text-sm"> (JSON)</Label>
<Textarea
value={formData.data_mapping}
onChange={(e) => setFormData({ ...formData, data_mapping: e.target.value })}
placeholder='{"source_field": "target_field"}'
className="min-h-[80px] font-mono text-xs sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
{/* 조건식 */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Input
value={formData.condition_expression}
onChange={(e) => setFormData({ ...formData, condition_expression: e.target.value })}
placeholder="예: data.status === 'active'"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{selectedFlow ? "수정" : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,417 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react";
import {
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
FieldJoin,
} from "@/lib/api/screenGroup";
interface FieldJoinPanelProps {
screenId: number;
componentId?: string;
layoutId?: number;
}
export default function FieldJoinPanel({ screenId, componentId, layoutId }: FieldJoinPanelProps) {
// 상태 관리
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedJoin, setSelectedJoin] = useState<FieldJoin | null>(null);
const [formData, setFormData] = useState({
field_name: "",
save_table: "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
sort_column: "",
sort_direction: "ASC",
is_active: "Y",
});
// 데이터 로드
const loadFieldJoins = useCallback(async () => {
if (!screenId) return;
setLoading(true);
try {
const response = await getFieldJoins(screenId);
if (response.success && response.data) {
// 현재 컴포넌트에 해당하는 조인만 필터링
const filtered = componentId
? response.data.filter(join => join.component_id === componentId)
: response.data;
setFieldJoins(filtered);
}
} catch (error) {
console.error("필드 조인 로드 실패:", error);
} finally {
setLoading(false);
}
}, [screenId, componentId]);
useEffect(() => {
loadFieldJoins();
}, [loadFieldJoins]);
// 모달 열기
const openModal = (join?: FieldJoin) => {
if (join) {
setSelectedJoin(join);
setFormData({
field_name: join.field_name || "",
save_table: join.save_table,
save_column: join.save_column,
join_table: join.join_table,
join_column: join.join_column,
display_column: join.display_column,
join_type: join.join_type,
filter_condition: join.filter_condition || "",
sort_column: join.sort_column || "",
sort_direction: join.sort_direction || "ASC",
is_active: join.is_active,
});
} else {
setSelectedJoin(null);
setFormData({
field_name: "",
save_table: "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
sort_column: "",
sort_direction: "ASC",
is_active: "Y",
});
}
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column || !formData.display_column) {
toast.error("필수 필드를 모두 입력해주세요.");
return;
}
try {
const payload = {
screen_id: screenId,
layout_id: layoutId,
component_id: componentId,
...formData,
};
let response;
if (selectedJoin) {
response = await updateFieldJoin(selectedJoin.id, payload);
} else {
response = await createFieldJoin(payload);
}
if (response.success) {
toast.success(selectedJoin ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
setIsModalOpen(false);
loadFieldJoins();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("이 조인 설정을 삭제하시겠습니까?")) return;
try {
const response = await deleteFieldJoin(id);
if (response.success) {
toast.success("조인 설정이 삭제되었습니다.");
loadFieldJoins();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
{/* 설명 */}
<p className="text-xs text-muted-foreground">
.
</p>
{/* 조인 목록 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : fieldJoins.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
<Database className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="h-8 text-xs"> .</TableHead>
<TableHead className="h-8 text-xs"> .</TableHead>
<TableHead className="h-8 text-xs"> </TableHead>
<TableHead className="h-8 w-[60px] text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fieldJoins.map((join) => (
<TableRow key={join.id} className="text-xs">
<TableCell className="py-2">
<span className="font-mono">{join.save_table}.{join.save_column}</span>
</TableCell>
<TableCell className="py-2">
<span className="font-mono">{join.join_table}.{join.join_column}</span>
</TableCell>
<TableCell className="py-2">
<span className="font-mono">{join.display_column}</span>
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(join)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(join.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 추가/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedJoin ? "조인 설정 수정" : "조인 설정 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 필드명 */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.field_name}
onChange={(e) => setFormData({ ...formData, field_name: e.target.value })}
placeholder="화면에 표시될 필드명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 저장 테이블/컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.save_table}
onChange={(e) => setFormData({ ...formData, save_table: e.target.value })}
placeholder="예: work_orders"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.save_column}
onChange={(e) => setFormData({ ...formData, save_column: e.target.value })}
placeholder="예: item_code"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 조인 테이블/컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.join_table}
onChange={(e) => setFormData({ ...formData, join_table: e.target.value })}
placeholder="예: item_mng"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.join_column}
onChange={(e) => setFormData({ ...formData, join_column: e.target.value })}
placeholder="예: id"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 표시 컬럼 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.display_column}
onChange={(e) => setFormData({ ...formData, display_column: e.target.value })}
placeholder="예: item_name (화면에 표시될 컬럼)"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 조인 타입/정렬 */}
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.join_type}
onValueChange={(value) => setFormData({ ...formData, join_type: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
<SelectItem value="INNER">INNER JOIN</SelectItem>
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.sort_column}
onChange={(e) => setFormData({ ...formData, sort_column: e.target.value })}
placeholder="예: name"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.sort_direction}
onValueChange={(value) => setFormData({ ...formData, sort_direction: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC"></SelectItem>
<SelectItem value="DESC"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 필터 조건 */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Textarea
value={formData.filter_condition}
onChange={(e) => setFormData({ ...formData, filter_condition: e.target.value })}
placeholder="예: is_active = 'Y'"
className="min-h-[60px] font-mono text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{selectedJoin ? "수정" : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -2,7 +2,7 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Database, ArrowLeft, Save, Monitor, Smartphone } from "lucide-react";
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages, Settings2 } from "lucide-react";
import { ScreenResolution } from "@/types/screen";
interface SlimToolbarProps {
@ -13,6 +13,9 @@ interface SlimToolbarProps {
onSave: () => void;
isSaving?: boolean;
onPreview?: () => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
onOpenMultilangSettings?: () => void;
}
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -23,6 +26,9 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onSave,
isSaving = false,
onPreview,
onGenerateMultilang,
isGeneratingMultilang = false,
onOpenMultilangSettings,
}) => {
return (
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
@ -70,6 +76,29 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
<span> </span>
</Button>
)}
{onGenerateMultilang && (
<Button
variant="outline"
onClick={onGenerateMultilang}
disabled={isGeneratingMultilang}
className="flex items-center space-x-2"
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
>
<Languages className="h-4 w-4" />
<span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span>
</Button>
)}
{onOpenMultilangSettings && (
<Button
variant="outline"
onClick={onOpenMultilangSettings}
className="flex items-center space-x-2"
title="다국어 키 연결 및 설정을 관리합니다"
>
<Settings2 className="h-4 w-4" />
<span> </span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>

View File

@ -32,14 +32,27 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
}
};
// 커스텀 색상 확인 (config 또는 style에서)
const hasCustomBg = config?.backgroundColor || style?.backgroundColor;
const hasCustomColor = config?.textColor || style?.color;
const hasCustomColors = hasCustomBg || hasCustomColor;
// 실제 적용할 배경색과 글자색
const bgColor = config?.backgroundColor || style?.backgroundColor;
const textColor = config?.textColor || style?.color;
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
if (isDesignMode) {
return (
<div
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${
hasCustomColors ? '' : 'bg-blue-600 text-white'
} ${className || ""}`}
style={{
...style,
backgroundColor: bgColor,
color: textColor,
width: "100%",
height: "100%",
cursor: "pointer", // 선택 가능하도록 포인터 표시
@ -56,9 +69,13 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
type="button"
onClick={handleClick}
disabled={disabled || readonly}
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${
hasCustomColors ? '' : 'bg-blue-600 text-white hover:bg-blue-700'
} ${className || ""}`}
style={{
...style,
backgroundColor: bgColor,
color: textColor,
width: "100%",
height: "100%",
}}

View File

@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue";
// 기본 색상 팔레트
@ -51,6 +52,7 @@ export const CategoryValueAddDialog: React.FC<
const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState("none");
const [continuousAdd, setContinuousAdd] = useState(false); // 연속 입력 체크박스
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
const generateCode = (): string => {
@ -60,6 +62,12 @@ export const CategoryValueAddDialog: React.FC<
return `CATEGORY_${timestamp}${random}`;
};
const resetForm = () => {
setValueLabel("");
setDescription("");
setColor("none");
};
const handleSubmit = () => {
if (!valueLabel.trim()) {
return;
@ -77,14 +85,28 @@ export const CategoryValueAddDialog: React.FC<
isDefault: false,
} as TableCategoryValue);
// 초기화
setValueLabel("");
setDescription("");
setColor("none");
// 연속 입력 체크되어 있으면 폼만 초기화하고 모달 유지
if (continuousAdd) {
resetForm();
} else {
// 연속 입력 아니면 모달 닫기
resetForm();
onOpenChange(false);
}
};
const handleClose = () => {
resetForm();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
resetForm();
}
onOpenChange(isOpen);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
@ -165,24 +187,42 @@ export const CategoryValueAddDialog: React.FC<
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<DialogFooter className="flex-col gap-3 sm:flex-row sm:gap-0">
{/* 연속 입력 체크박스 */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:mr-auto">
<Checkbox
id="continuousAdd"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<label
htmlFor="continuousAdd"
className="text-xs sm:text-sm text-muted-foreground cursor-pointer"
>
</label>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -123,7 +123,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
if (response.success && response.data) {
await loadCategoryValues();
setIsAddDialogOpen(false);
// 모달 닫기는 CategoryValueAddDialog에서 연속 입력 체크박스로 제어
toast({
title: "성공",
description: "카테고리 값이 추가되었습니다",
@ -142,7 +142,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
title: "오류",
description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
});
}
};

View File

@ -16,7 +16,9 @@ import {
RepeaterItemData,
RepeaterFieldDefinition,
CalculationFormula,
SubDataState,
} from "@/types/repeater";
import { SubDataLookupPanel } from "@/lib/registry/components/repeater-field-group/SubDataLookupPanel";
import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -68,8 +70,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
layout = "grid", // 기본값을 grid로 설정
showDivider = true,
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
subDataLookup,
} = config;
// 하위 데이터 조회 상태 관리 (각 항목별)
const [subDataStates, setSubDataStates] = useState<Map<number, SubDataState>>(new Map());
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
@ -272,6 +278,111 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 드래그 앤 드롭 (순서 변경)
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// 하위 데이터 선택 핸들러
const handleSubDataSelection = (itemIndex: number, selectedItem: any | null, maxValue: number | null) => {
console.log("[RepeaterInput] 하위 데이터 선택:", { itemIndex, selectedItem, maxValue });
// 상태 업데이트
setSubDataStates((prev) => {
const newMap = new Map(prev);
const currentState = newMap.get(itemIndex) || {
itemIndex,
data: [],
selectedItem: null,
isLoading: false,
error: null,
isExpanded: false,
};
newMap.set(itemIndex, {
...currentState,
selectedItem,
});
return newMap;
});
// 선택된 항목 정보를 item에 저장
if (selectedItem && subDataLookup) {
const newItems = [...items];
newItems[itemIndex] = {
...newItems[itemIndex],
_subDataSelection: selectedItem,
_subDataMaxValue: maxValue,
};
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
// 예: warehouse_code, location_code 등
if (subDataLookup.lookup.displayColumns) {
subDataLookup.lookup.displayColumns.forEach((col) => {
if (selectedItem[col] !== undefined) {
// 필드가 정의되어 있으면 복사
const fieldDef = fields.find((f) => f.name === col);
if (fieldDef || col.includes("_code") || col.includes("_id")) {
newItems[itemIndex][col] = selectedItem[col];
}
}
});
}
setItems(newItems);
// onChange 호출
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
: newItems;
onChange?.(dataWithMeta);
}
};
// 조건부 입력 활성화 여부 확인
const isConditionalInputEnabled = (itemIndex: number, fieldName: string): boolean => {
if (!subDataLookup?.enabled) return true;
if (subDataLookup.conditionalInput?.targetField !== fieldName) return true;
const subState = subDataStates.get(itemIndex);
if (!subState?.selectedItem) return false;
const { requiredFields, requiredMode = "all" } = subDataLookup.selection;
if (!requiredFields || requiredFields.length === 0) return true;
if (requiredMode === "any") {
return requiredFields.some((field) => {
const value = subState.selectedItem[field];
return value !== undefined && value !== null && value !== "";
});
} else {
return requiredFields.every((field) => {
const value = subState.selectedItem[field];
return value !== undefined && value !== null && value !== "";
});
}
};
// 최대값 가져오기
const getMaxValueForField = (itemIndex: number, fieldName: string): number | null => {
if (!subDataLookup?.enabled) return null;
if (subDataLookup.conditionalInput?.targetField !== fieldName) return null;
if (!subDataLookup.conditionalInput?.maxValueField) return null;
const subState = subDataStates.get(itemIndex);
if (!subState?.selectedItem) return null;
const maxVal = subState.selectedItem[subDataLookup.conditionalInput.maxValueField];
return typeof maxVal === "number" ? maxVal : parseFloat(maxVal) || null;
};
// 경고 임계값 체크
const checkWarningThreshold = (itemIndex: number, fieldName: string, value: number): boolean => {
if (!subDataLookup?.enabled) return false;
if (subDataLookup.conditionalInput?.targetField !== fieldName) return false;
const maxValue = getMaxValueForField(itemIndex, fieldName);
if (maxValue === null || maxValue === 0) return false;
const threshold = subDataLookup.conditionalInput?.warningThreshold ?? 90;
const percentage = (value / maxValue) * 100;
return percentage >= threshold;
};
const handleDragStart = (index: number) => {
if (!allowReorder || readonly || disabled) return;
setDraggedIndex(index);
@ -389,14 +500,26 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly;
// 조건부 입력 비활성화 체크
const isConditionalDisabled =
subDataLookup?.enabled &&
subDataLookup.conditionalInput?.targetField === field.name &&
!isConditionalInputEnabled(itemIndex, field.name);
// 최대값 및 경고 체크
const maxValue = getMaxValueForField(itemIndex, field.name);
const numValue = parseFloat(value) || 0;
const showWarning = checkWarningThreshold(itemIndex, field.name, numValue);
const exceedsMax = maxValue !== null && numValue > maxValue;
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
const commonProps = {
value: value || "",
disabled: isReadonly,
placeholder: defaultPlaceholder,
disabled: isReadonly || isConditionalDisabled,
placeholder: isConditionalDisabled ? "재고 선택 필요" : defaultPlaceholder,
required: field.required,
};
@ -569,23 +692,37 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="pr-1"
max={maxValue !== null ? maxValue : field.validation?.max}
className={cn("pr-1", exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")}
/>
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
{exceedsMax && (
<div className="mt-0.5 text-[10px] text-red-500"> {maxValue} </div>
)}
{showWarning && !exceedsMax && (
<div className="mt-0.5 text-[10px] text-amber-600"> {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% </div>
)}
</div>
);
}
return (
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="min-w-[80px]"
/>
<div className="relative min-w-[80px]">
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={maxValue !== null ? maxValue : field.validation?.max}
className={cn(exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")}
/>
{exceedsMax && (
<div className="mt-0.5 text-[10px] text-red-500"> {maxValue} </div>
)}
{showWarning && !exceedsMax && (
<div className="mt-0.5 text-[10px] text-amber-600"> {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% </div>
)}
</div>
);
case "email":
@ -754,6 +891,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 그리드/테이블 형식 렌더링
const renderGridLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return (
<div className="bg-card">
<Table>
@ -775,55 +915,83 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, itemIndex) => (
<TableRow
key={itemIndex}
className={cn(
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
onDragStart={() => handleDragStart(itemIndex)}
onDragOver={(e) => handleDragOver(e, itemIndex)}
onDrop={(e) => handleDrop(e, itemIndex)}
onDragEnd={handleDragEnd}
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{items.map((item, itemIndex) => {
// 하위 데이터 조회용 연결 값
const linkValue = linkColumn ? item[linkColumn] : null;
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
return (
<React.Fragment key={itemIndex}>
<TableRow
className={cn(
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
onDragStart={() => handleDragStart(itemIndex)}
onDragOver={(e) => handleDragOver(e, itemIndex)}
onDrop={(e) => handleDrop(e, itemIndex)}
onDragEnd={handleDragEnd}
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
</Button>
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
{/* 하위 데이터 조회 패널 (인라인) */}
{subDataLookup?.enabled && linkValue && (
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={
fields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
}
className="px-2.5 py-2"
>
<SubDataLookupPanel
config={subDataLookup}
linkValue={linkValue}
itemIndex={itemIndex}
onSelectionChange={(selectedItem, maxValue) =>
handleSubDataSelection(itemIndex, selectedItem, maxValue)
}
disabled={readonly || disabled}
/>
</TableCell>
</TableRow>
)}
</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
@ -832,10 +1000,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 카드 형식 렌더링 (기존 방식)
const renderCardLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return (
<>
{items.map((item, itemIndex) => {
const isCollapsed = collapsible && collapsedItems.has(itemIndex);
// 하위 데이터 조회용 연결 값
const linkValue = linkColumn ? item[linkColumn] : null;
return (
<Card
@ -907,6 +1080,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</div>
))}
</div>
{/* 하위 데이터 조회 패널 (인라인) */}
{subDataLookup?.enabled && linkValue && (
<div className="mt-3 border-t pt-3">
<SubDataLookupPanel
config={subDataLookup}
linkValue={linkValue}
itemIndex={itemIndex}
onSelectionChange={(selectedItem, maxValue) =>
handleSubDataSelection(itemIndex, selectedItem, maxValue)
}
disabled={readonly || disabled}
/>
</div>
)}
</CardContent>
)}

View File

@ -9,14 +9,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator, Database, ArrowUp, ArrowDown } from "lucide-react";
import {
RepeaterFieldGroupConfig,
RepeaterFieldDefinition,
RepeaterFieldType,
CalculationOperator,
CalculationFormula,
SubDataLookupConfig,
} from "@/types/repeater";
import { apiClient } from "@/lib/api/client";
import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils";
@ -93,6 +96,56 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
handleFieldsChange(localFields.filter((_, i) => i !== index));
};
// 필드 순서 변경 (위로)
const moveFieldUp = (index: number) => {
if (index <= 0) return;
const newFields = [...localFields];
[newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]];
handleFieldsChange(newFields);
};
// 필드 순서 변경 (아래로)
const moveFieldDown = (index: number) => {
if (index >= localFields.length - 1) return;
const newFields = [...localFields];
[newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
handleFieldsChange(newFields);
};
// 드래그 앤 드롭 상태
const [draggedFieldIndex, setDraggedFieldIndex] = useState<number | null>(null);
// 필드 드래그 시작
const handleFieldDragStart = (index: number) => {
setDraggedFieldIndex(index);
};
// 필드 드래그 오버
const handleFieldDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
};
// 필드 드롭
const handleFieldDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedFieldIndex === null || draggedFieldIndex === targetIndex) {
setDraggedFieldIndex(null);
return;
}
const newFields = [...localFields];
const draggedField = newFields[draggedFieldIndex];
newFields.splice(draggedFieldIndex, 1);
newFields.splice(targetIndex, 0, draggedField);
handleFieldsChange(newFields);
setDraggedFieldIndex(null);
};
// 필드 드래그 종료
const handleFieldDragEnd = () => {
setDraggedFieldIndex(null);
};
// 필드 수정 (입력 중 - 로컬 상태만)
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
setLocalInputs((prev) => ({
@ -129,6 +182,46 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 하위 데이터 조회 설정 상태
const [subDataTableSelectOpen, setSubDataTableSelectOpen] = useState(false);
const [subDataTableSearchValue, setSubDataTableSearchValue] = useState("");
const [subDataTableColumns, setSubDataTableColumns] = useState<ColumnInfo[]>([]);
const [subDataLinkColumnOpen, setSubDataLinkColumnOpen] = useState(false);
const [subDataLinkColumnSearch, setSubDataLinkColumnSearch] = useState("");
// 하위 데이터 조회 테이블 컬럼 로드
const loadSubDataTableColumns = async (tableName: string) => {
if (!tableName) {
setSubDataTableColumns([]);
return;
}
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
let columns: ColumnInfo[] = [];
if (response.data?.success && response.data?.data) {
if (Array.isArray(response.data.data.columns)) {
columns = response.data.data.columns;
} else if (Array.isArray(response.data.data)) {
columns = response.data.data;
}
} else if (Array.isArray(response.data)) {
columns = response.data;
}
setSubDataTableColumns(columns);
console.log("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드:", { tableName, count: columns.length });
} catch (error) {
console.error("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드 실패:", error);
setSubDataTableColumns([]);
}
};
// 하위 데이터 테이블이 설정되어 있으면 컬럼 로드
useEffect(() => {
if (config.subDataLookup?.lookup?.tableName) {
loadSubDataTableColumns(config.subDataLookup.lookup.tableName);
}
}, [config.subDataLookup?.lookup?.tableName]);
// 필터링된 테이블 목록
const filteredTables = useMemo(() => {
if (!tableSearchValue) return allTables;
@ -146,6 +239,86 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
return table ? table.displayName || table.tableName : config.targetTable;
}, [config.targetTable, allTables]);
// 하위 데이터 조회 테이블 표시명
const selectedSubDataTableLabel = useMemo(() => {
const tableName = config.subDataLookup?.lookup?.tableName;
if (!tableName) return "테이블을 선택하세요";
const table = allTables.find((t) => t.tableName === tableName);
return table ? `${table.displayName || table.tableName} (${tableName})` : tableName;
}, [config.subDataLookup?.lookup?.tableName, allTables]);
// 필터링된 하위 데이터 테이블 컬럼
const filteredSubDataColumns = useMemo(() => {
if (!subDataLinkColumnSearch) return subDataTableColumns;
const searchLower = subDataLinkColumnSearch.toLowerCase();
return subDataTableColumns.filter(
(col) =>
col.columnName.toLowerCase().includes(searchLower) ||
(col.columnLabel && col.columnLabel.toLowerCase().includes(searchLower)),
);
}, [subDataTableColumns, subDataLinkColumnSearch]);
// 하위 데이터 조회 설정 변경 핸들러
const handleSubDataLookupChange = (path: string, value: any) => {
const currentConfig = config.subDataLookup || {
enabled: false,
lookup: { tableName: "", linkColumn: "", displayColumns: [] },
selection: { mode: "single", requiredFields: [], requiredMode: "all" },
conditionalInput: { targetField: "" },
ui: { expandMode: "inline", maxHeight: "150px", showSummary: true },
};
// 경로를 따라 중첩 객체 업데이트
const pathParts = path.split(".");
let target: any = { ...currentConfig };
const newConfig = target;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
target[part] = { ...target[part] };
target = target[part];
}
target[pathParts[pathParts.length - 1]] = value;
onChange({
...config,
subDataLookup: newConfig as SubDataLookupConfig,
});
};
// 표시 컬럼 토글 핸들러
const handleDisplayColumnToggle = (columnName: string, checked: boolean) => {
const currentColumns = config.subDataLookup?.lookup?.displayColumns || [];
let newColumns: string[];
if (checked) {
newColumns = [...currentColumns, columnName];
} else {
newColumns = currentColumns.filter((c) => c !== columnName);
}
handleSubDataLookupChange("lookup.displayColumns", newColumns);
};
// 필수 선택 필드 토글 핸들러
const handleRequiredFieldToggle = (fieldName: string, checked: boolean) => {
const currentFields = config.subDataLookup?.selection?.requiredFields || [];
let newFields: string[];
if (checked) {
newFields = [...currentFields, fieldName];
} else {
newFields = currentFields.filter((f) => f !== fieldName);
}
handleSubDataLookupChange("selection.requiredFields", newFields);
};
// 컬럼 라벨 업데이트 핸들러
const handleColumnLabelChange = (columnName: string, label: string) => {
const currentLabels = config.subDataLookup?.lookup?.columnLabels || {};
handleSubDataLookupChange("lookup.columnLabels", {
...currentLabels,
[columnName]: label,
});
};
return (
<div className="space-y-4">
{/* 대상 테이블 선택 */}
@ -250,24 +423,485 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p>
</div>
{/* 하위 데이터 조회 설정 */}
<div className="space-y-3 rounded-lg border-2 border-purple-200 bg-purple-50/30 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-purple-600" />
<Label className="text-sm font-semibold text-purple-800"> </Label>
</div>
<Switch
checked={config.subDataLookup?.enabled ?? false}
onCheckedChange={(checked) => handleSubDataLookupChange("enabled", checked)}
/>
</div>
<p className="text-xs text-purple-600">
/ .
</p>
{config.subDataLookup?.enabled && (
<div className="space-y-4 pt-2">
{/* 조회 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<Popover open={subDataTableSelectOpen} onOpenChange={setSubDataTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDataTableSelectOpen}
className="h-9 w-full justify-between text-xs"
>
{selectedSubDataTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="테이블 검색..."
value={subDataTableSearchValue}
onValueChange={setSubDataTableSearchValue}
className="h-8 text-xs"
/>
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto">
{allTables
.filter((table) => {
if (!subDataTableSearchValue) return true;
const searchLower = subDataTableSearchValue.toLowerCase();
return (
table.tableName.toLowerCase().includes(searchLower) ||
(table.displayName && table.displayName.toLowerCase().includes(searchLower))
);
})
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
handleSubDataLookupChange("lookup.tableName", currentValue);
loadSubDataTableColumns(currentValue);
setSubDataTableSelectOpen(false);
setSubDataTableSearchValue("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.subDataLookup?.lookup?.tableName === table.tableName
? "opacity-100"
: "opacity-0",
)}
/>
<div>
<div className="font-medium">{table.displayName || table.tableName}</div>
<div className="text-gray-500">{table.tableName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-purple-500">: inventory (), price_list ()</p>
</div>
{/* 연결 컬럼 선택 */}
{config.subDataLookup?.lookup?.tableName && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<Popover open={subDataLinkColumnOpen} onOpenChange={setSubDataLinkColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDataLinkColumnOpen}
className="h-9 w-full justify-between text-xs"
>
{config.subDataLookup?.lookup?.linkColumn
? (() => {
const col = subDataTableColumns.find(
(c) => c.columnName === config.subDataLookup?.lookup?.linkColumn,
);
return col
? `${col.columnLabel || col.columnName} (${col.columnName})`
: config.subDataLookup?.lookup?.linkColumn;
})()
: "연결 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={subDataLinkColumnSearch}
onValueChange={setSubDataLinkColumnSearch}
className="h-8 text-xs"
/>
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto">
{filteredSubDataColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={(currentValue) => {
handleSubDataLookupChange("lookup.linkColumn", currentValue);
setSubDataLinkColumnOpen(false);
setSubDataLinkColumnSearch("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.subDataLookup?.lookup?.linkColumn === col.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div>
<div className="font-medium">{col.columnLabel || col.columnName}</div>
<div className="text-gray-500">{col.columnName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-purple-500"> (: item_code)</p>
</div>
)}
{/* 표시 컬럼 선택 */}
{config.subDataLookup?.lookup?.tableName && subDataTableColumns.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-white p-2">
{subDataTableColumns.map((col) => {
const isSelected = config.subDataLookup?.lookup?.displayColumns?.includes(col.columnName);
return (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`display-col-${col.columnName}`}
checked={isSelected}
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)}
/>
<Label
htmlFor={`display-col-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{col.columnLabel || col.columnName}
<span className="ml-1 text-gray-400">({col.columnName})</span>
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500"> (: 창고, , )</p>
</div>
)}
{/* 선택 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700"> </Label>
{/* 선택 모드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.selection?.mode || "single"}
onValueChange={(v) => handleSubDataLookupChange("selection.mode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single" className="text-xs">
</SelectItem>
<SelectItem value="multiple" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 필수 선택 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<div className="flex flex-wrap gap-2">
{config.subDataLookup?.lookup?.displayColumns?.map((colName) => {
const col = subDataTableColumns.find((c) => c.columnName === colName);
const isRequired = config.subDataLookup?.selection?.requiredFields?.includes(colName);
return (
<div key={colName} className="flex items-center gap-1">
<Checkbox
id={`required-field-${colName}`}
checked={isRequired}
onCheckedChange={(checked) => handleRequiredFieldToggle(colName, checked as boolean)}
/>
<Label htmlFor={`required-field-${colName}`} className="cursor-pointer text-xs font-normal">
{col?.columnLabel || colName}
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500"> </p>
</div>
{/* 필수 조건 */}
{(config.subDataLookup?.selection?.requiredFields?.length || 0) > 1 && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.selection?.requiredMode || "all"}
onValueChange={(v) => handleSubDataLookupChange("selection.requiredMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">
</SelectItem>
<SelectItem value="any" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 조건부 입력 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700"> </Label>
{/* 활성화 대상 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.conditionalInput?.targetField || "__none__"}
onValueChange={(v) =>
handleSubDataLookupChange("conditionalInput.targetField", v === "__none__" ? "" : v)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{localFields.length === 0 ? (
<SelectItem value="__empty__" disabled className="text-xs text-gray-400">
</SelectItem>
) : (
localFields.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name} ({f.name})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500">
(: 출고수량)
{localFields.length === 0 && (
<span className="ml-1 text-amber-600">* </span>
)}
</p>
</div>
{/* 최대값 참조 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> ()</Label>
<Select
value={config.subDataLookup?.conditionalInput?.maxValueField || "__none__"}
onValueChange={(v) =>
handleSubDataLookupChange("conditionalInput.maxValueField", v === "__none__" ? undefined : v)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{subDataTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500"> (: 재고수량)</p>
</div>
{/* 경고 임계값 */}
{config.subDataLookup?.conditionalInput?.maxValueField && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> (%)</Label>
<Input
type="number"
min={0}
max={100}
value={config.subDataLookup?.conditionalInput?.warningThreshold ?? 90}
onChange={(e) =>
handleSubDataLookupChange("conditionalInput.warningThreshold", parseInt(e.target.value) || 90)
}
className="h-8 text-xs"
/>
<p className="text-[10px] text-purple-500"> (: 90%)</p>
</div>
)}
</div>
)}
{/* UI 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700">UI </Label>
{/* 확장 방식 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.ui?.expandMode || "inline"}
onValueChange={(v) => handleSubDataLookupChange("ui.expandMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inline" className="text-xs">
( )
</SelectItem>
<SelectItem value="modal" className="text-xs">
()
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 최대 높이 */}
{config.subDataLookup?.ui?.expandMode === "inline" && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Input
value={config.subDataLookup?.ui?.maxHeight || "150px"}
onChange={(e) => handleSubDataLookupChange("ui.maxHeight", e.target.value)}
placeholder="150px"
className="h-8 text-xs"
/>
</div>
)}
{/* 요약 정보 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="sub-data-show-summary"
checked={config.subDataLookup?.ui?.showSummary ?? true}
onCheckedChange={(checked) => handleSubDataLookupChange("ui.showSummary", checked)}
/>
<Label htmlFor="sub-data-show-summary" className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
)}
{/* 설정 요약 */}
{config.subDataLookup?.lookup?.tableName && (
<div className="rounded bg-purple-100 p-2 text-xs">
<p className="font-medium text-purple-800"> </p>
<ul className="mt-1 space-y-0.5 text-purple-700">
<li> : {config.subDataLookup?.lookup?.tableName || "-"}</li>
<li> : {config.subDataLookup?.lookup?.linkColumn || "-"}</li>
<li> : {config.subDataLookup?.lookup?.displayColumns?.join(", ") || "-"}</li>
<li> : {config.subDataLookup?.selection?.requiredFields?.join(", ") || "-"}</li>
<li> : {config.subDataLookup?.conditionalInput?.targetField || "-"}</li>
</ul>
</div>
)}
</div>
)}
</div>
{/* 필드 정의 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<span className="text-xs text-gray-500"> </span>
</div>
{localFields.map((field, index) => (
<Card key={`${field.name}-${index}`} className="border-2">
<Card
key={`${field.name}-${index}`}
className={cn(
"border-2 transition-all",
draggedFieldIndex === index && "opacity-50 border-blue-400",
draggedFieldIndex !== null && draggedFieldIndex !== index && "border-dashed",
)}
draggable
onDragStart={() => handleFieldDragStart(index)}
onDragOver={(e) => handleFieldDragOver(e, index)}
onDrop={(e) => handleFieldDrop(e, index)}
onDragEnd={handleFieldDragEnd}
>
<CardContent className="space-y-3 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-6 w-6 text-red-500 hover:bg-red-50"
>
<X className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
<GripVertical className="h-4 w-4 cursor-move text-gray-400 hover:text-gray-600" />
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
</div>
<div className="flex items-center gap-1">
{/* 순서 변경 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => moveFieldUp(index)}
disabled={index === 0}
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
title="위로 이동"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => moveFieldDown(index)}
disabled={index === localFields.length - 1}
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
title="아래로 이동"
>
<ArrowDown className="h-3 w-3" />
</Button>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-6 w-6 text-red-500 hover:bg-red-50"
title="삭제"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">

View File

@ -141,3 +141,4 @@ export const useActiveTabOptional = () => {

View File

@ -13,6 +13,7 @@ import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
interface ScreenContextValue {
screenId?: number;
tableName?: string;
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
@ -39,6 +40,7 @@ const ScreenContext = createContext<ScreenContextValue | null>(null);
interface ScreenContextProviderProps {
screenId?: number;
tableName?: string;
menuObjid?: number; // 메뉴 OBJID
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
children: React.ReactNode;
}
@ -49,6 +51,7 @@ interface ScreenContextProviderProps {
export function ScreenContextProvider({
screenId,
tableName,
menuObjid,
splitPanelPosition,
children,
}: ScreenContextProviderProps) {
@ -112,6 +115,7 @@ export function ScreenContextProvider({
() => ({
screenId,
tableName,
menuObjid,
splitPanelPosition,
formData,
updateFormData,
@ -127,6 +131,7 @@ export function ScreenContextProvider({
[
screenId,
tableName,
menuObjid,
splitPanelPosition,
formData,
updateFormData,

View File

@ -0,0 +1,182 @@
"use client";
import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from "react";
import { apiClient } from "@/lib/api/client";
import { useMultiLang } from "@/hooks/useMultiLang";
import { ComponentData } from "@/types/screen";
interface ScreenMultiLangContextValue {
translations: Record<string, string>;
loading: boolean;
getTranslatedText: (langKey: string | undefined, fallback: string) => string;
}
const ScreenMultiLangContext = createContext<ScreenMultiLangContextValue | null>(null);
interface ScreenMultiLangProviderProps {
children: ReactNode;
components: ComponentData[];
companyCode?: string;
}
/**
* Provider
* langKey를 ,
*/
export const ScreenMultiLangProvider: React.FC<ScreenMultiLangProviderProps> = ({
children,
components,
companyCode = "*",
}) => {
const { userLang } = useMultiLang();
const [translations, setTranslations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
// 모든 컴포넌트에서 langKey 수집
const langKeys = useMemo(() => {
const keys: string[] = [];
const collectLangKeys = (comps: ComponentData[]) => {
comps.forEach((comp) => {
// 컴포넌트 라벨의 langKey
if ((comp as any).langKey) {
keys.push((comp as any).langKey);
}
// componentConfig 내의 langKey (버튼 텍스트 등)
if ((comp as any).componentConfig?.langKey) {
keys.push((comp as any).componentConfig.langKey);
}
// properties 내의 langKey (레거시)
if ((comp as any).properties?.langKey) {
keys.push((comp as any).properties.langKey);
}
// 테이블 리스트 컬럼의 langKey 수집
if ((comp as any).componentConfig?.columns) {
(comp as any).componentConfig.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
// 분할패널 좌측/우측 제목 langKey 수집
const config = (comp as any).componentConfig;
if (config?.leftPanel?.langKey) {
keys.push(config.leftPanel.langKey);
}
if (config?.rightPanel?.langKey) {
keys.push(config.rightPanel.langKey);
}
// 분할패널 좌측/우측 컬럼 langKey 수집
if (config?.leftPanel?.columns) {
config.leftPanel.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
if (config?.rightPanel?.columns) {
config.rightPanel.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
// 추가 탭 langKey 수집
if (config?.additionalTabs) {
config.additionalTabs.forEach((tab: any) => {
if (tab.langKey) {
keys.push(tab.langKey);
}
if (tab.titleLangKey) {
keys.push(tab.titleLangKey);
}
if (tab.columns) {
tab.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
});
}
// 자식 컴포넌트 재귀 처리
if ((comp as any).children) {
collectLangKeys((comp as any).children);
}
});
};
collectLangKeys(components);
return [...new Set(keys)]; // 중복 제거
}, [components]);
// langKey가 있으면 배치 조회
useEffect(() => {
const loadTranslations = async () => {
if (langKeys.length === 0 || !userLang) {
return;
}
setLoading(true);
try {
console.log("🌐 [ScreenMultiLang] 다국어 배치 로드:", { langKeys: langKeys.length, userLang, companyCode });
const response = await apiClient.post(
"/multilang/batch",
{ langKeys },
{
params: {
userLang,
companyCode,
},
}
);
if (response.data?.success && response.data?.data) {
console.log("✅ [ScreenMultiLang] 다국어 로드 완료:", Object.keys(response.data.data).length, "개");
setTranslations(response.data.data);
}
} catch (error) {
console.error("❌ [ScreenMultiLang] 다국어 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadTranslations();
}, [langKeys, userLang, companyCode]);
// 번역 텍스트 가져오기 헬퍼
const getTranslatedText = (langKey: string | undefined, fallback: string): string => {
if (!langKey) return fallback;
return translations[langKey] || fallback;
};
const value = useMemo(
() => ({
translations,
loading,
getTranslatedText,
}),
[translations, loading]
);
return <ScreenMultiLangContext.Provider value={value}>{children}</ScreenMultiLangContext.Provider>;
};
/**
*
*/
export const useScreenMultiLang = (): ScreenMultiLangContextValue => {
const context = useContext(ScreenMultiLangContext);
if (!context) {
// 컨텍스트가 없으면 기본값 반환 (fallback)
return {
translations: {},
loading: false,
getTranslatedText: (_, fallback) => fallback,
};
}
return context;
};

View File

@ -198,3 +198,4 @@ export function applyAutoFillToFormData(

View File

@ -202,14 +202,19 @@ export class DynamicFormApi {
*
* @param id ID
* @param tableName
* @param screenId ID ( , )
* @returns
*/
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
static async deleteFormDataFromTable(
id: string | number,
tableName: string,
screenId?: number
): Promise<ApiResponse<void>> {
try {
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId });
await apiClient.delete(`/dynamic-form/${id}`, {
data: { tableName },
data: { tableName, screenId },
});
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
@ -556,6 +561,192 @@ export class DynamicFormApi {
};
}
}
// ================================
// 마스터-디테일 엑셀 API
// ================================
/**
* -
* @param screenId ID
* @returns - (null이면 - )
*/
static async getMasterDetailRelation(screenId: number): Promise<ApiResponse<MasterDetailRelation | null>> {
try {
console.log("🔍 마스터-디테일 관계 조회:", screenId);
const response = await apiClient.get(`/data/master-detail/relation/${screenId}`);
return {
success: true,
data: response.data?.data || null,
message: response.data?.message || "조회 완료",
};
} catch (error: any) {
console.error("❌ 마스터-디테일 관계 조회 실패:", error);
return {
success: false,
data: null,
message: error.response?.data?.message || error.message,
};
}
}
/**
* -
* @param screenId ID
* @param filters
* @returns JOIN된
*/
static async getMasterDetailDownloadData(
screenId: number,
filters?: Record<string, any>
): Promise<ApiResponse<MasterDetailDownloadData>> {
try {
console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters });
const response = await apiClient.post(`/data/master-detail/download`, {
screenId,
filters,
});
return {
success: true,
data: response.data?.data,
message: "데이터 조회 완료",
};
} catch (error: any) {
console.error("❌ 마스터-디테일 다운로드 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
/**
* -
* @param screenId ID
* @param data
* @returns
*/
static async uploadMasterDetailData(
screenId: number,
data: Record<string, any>[]
): Promise<ApiResponse<MasterDetailUploadResult>> {
try {
console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length });
const response = await apiClient.post(`/data/master-detail/upload`, {
screenId,
data,
});
return {
success: response.data?.success,
data: response.data?.data,
message: response.data?.message,
};
} catch (error: any) {
console.error("❌ 마스터-디테일 업로드 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
/**
* -
* - UI에서
* -
* -
* @param screenId ID
* @param detailData
* @param masterFieldValues UI에서
* @param numberingRuleId ID (optional)
* @param afterUploadFlowId ID (optional, )
* @param afterUploadFlows (optional)
* @returns
*/
static async uploadMasterDetailSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId?: string,
afterUploadFlowId?: string,
afterUploadFlows?: Array<{ flowId: string; order: number }>
): Promise<ApiResponse<MasterDetailSimpleUploadResult>> {
try {
console.log("📤 마스터-디테일 간단 모드 업로드:", {
screenId,
detailRowCount: detailData.length,
masterFieldValues,
numberingRuleId,
afterUploadFlows: afterUploadFlows?.length || 0,
});
const response = await apiClient.post(`/data/master-detail/upload-simple`, {
screenId,
detailData,
masterFieldValues,
numberingRuleId,
afterUploadFlowId,
afterUploadFlows,
});
return {
success: response.data?.success,
data: response.data?.data,
message: response.data?.message,
};
} catch (error: any) {
console.error("❌ 마스터-디테일 간단 모드 업로드 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
}
// 마스터-디테일 관계 타입
export interface MasterDetailRelation {
masterTable: string;
detailTable: string;
masterKeyColumn: string;
detailFkColumn: string;
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
}
// 마스터-디테일 다운로드 데이터 타입
export interface MasterDetailDownloadData {
headers: string[];
columns: string[];
data: Record<string, any>[];
masterColumns: string[];
detailColumns: string[];
joinKey: string;
}
// 마스터-디테일 업로드 결과 타입
export interface MasterDetailUploadResult {
success: boolean;
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailDeleted: number;
errors: string[];
}
// 🆕 마스터-디테일 간단 모드 업로드 결과 타입
export interface MasterDetailSimpleUploadResult {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string; // 생성된 마스터 키
errors?: string[];
}
// 편의를 위한 기본 export

View File

@ -77,21 +77,26 @@ export const entityJoinApi = {
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
companyCodeOverride?: string; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
} = {},
): Promise<EntityJoinResponse> => {
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
const autoFilter = {
const autoFilter: {
enabled: boolean;
filterColumn: string;
userField: string;
companyCodeOverride?: string;
} = {
enabled: true,
filterColumn: "company_code",
userField: "companyCode",
};
// 🆕 프리뷰 모드에서 회사 코드 오버라이드 (최고 관리자만 백엔드에서 허용)
if (params.companyCodeOverride) {
autoFilter.companyCodeOverride = params.companyCodeOverride;
}
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
params: {
page: params.page,
@ -102,10 +107,9 @@ export const entityJoinApi = {
search: params.search ? JSON.stringify(params.search) : undefined,
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 (오버라이드 포함)
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
},
});
return response.data.data;

View File

@ -0,0 +1,402 @@
/**
* API
* , ,
*/
import { apiClient } from "./client";
// =====================================================
// 타입 정의
// =====================================================
export interface Language {
langCode: string;
langName: string;
langNative: string;
isActive: string;
sortOrder?: number;
}
export interface LangCategory {
categoryId: number;
categoryCode: string;
categoryName: string;
parentId?: number | null;
level: number;
keyPrefix: string;
description?: string;
sortOrder: number;
isActive: string;
children?: LangCategory[];
}
export interface LangKey {
keyId?: number;
companyCode: string;
menuName?: string;
langKey: string;
description?: string;
isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date;
}
export interface LangText {
textId?: number;
keyId: number;
langCode: string;
langText: string;
isActive: string;
}
export interface GenerateKeyRequest {
companyCode: string;
categoryId: number;
keyMeaning: string;
usageNote?: string;
texts: Array<{
langCode: string;
langText: string;
}>;
}
export interface CreateOverrideKeyRequest {
companyCode: string;
baseKeyId: number;
texts: Array<{
langCode: string;
langText: string;
}>;
}
export interface KeyPreview {
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}
export interface ApiResponse<T> {
success: boolean;
message?: string;
data?: T;
error?: {
code: string;
details?: any;
};
}
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
*
*/
export async function getCategories(): Promise<ApiResponse<LangCategory[]>> {
try {
const response = await apiClient.get("/multilang/categories");
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function getCategoryById(categoryId: number): Promise<ApiResponse<LangCategory>> {
try {
const response = await apiClient.get(`/multilang/categories/${categoryId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* ( )
*/
export async function getCategoryPath(categoryId: number): Promise<ApiResponse<LangCategory[]>> {
try {
const response = await apiClient.get(`/multilang/categories/${categoryId}/path`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_PATH_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 언어 관련 API
// =====================================================
/**
*
*/
export async function getLanguages(): Promise<ApiResponse<Language[]>> {
try {
const response = await apiClient.get("/multilang/languages");
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "LANGUAGE_FETCH_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 키 관련 API
// =====================================================
/**
*
*/
export async function getLangKeys(params?: {
companyCode?: string;
menuCode?: string;
categoryId?: number;
searchText?: string;
}): Promise<ApiResponse<LangKey[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.companyCode) queryParams.append("companyCode", params.companyCode);
if (params?.menuCode) queryParams.append("menuCode", params.menuCode);
if (params?.categoryId) queryParams.append("categoryId", params.categoryId.toString());
if (params?.searchText) queryParams.append("searchText", params.searchText);
const url = `/multilang/keys${queryParams.toString() ? `?${queryParams.toString()}` : ""}`;
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEYS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function getLangTexts(keyId: number): Promise<ApiResponse<LangText[]>> {
try {
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "TEXTS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function generateKey(data: GenerateKeyRequest): Promise<ApiResponse<number>> {
try {
const response = await apiClient.post("/multilang/keys/generate", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_GENERATE_ERROR",
details: error.response?.data?.error?.details || error.message,
},
};
}
}
/**
*
*/
export async function previewKey(
categoryId: number,
keyMeaning: string,
companyCode: string
): Promise<ApiResponse<KeyPreview>> {
try {
const response = await apiClient.post("/multilang/keys/preview", {
categoryId,
keyMeaning,
companyCode,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_PREVIEW_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function createOverrideKey(
data: CreateOverrideKeyRequest
): Promise<ApiResponse<number>> {
try {
const response = await apiClient.post("/multilang/keys/override", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "OVERRIDE_CREATE_ERROR",
details: error.response?.data?.error?.details || error.message,
},
};
}
}
/**
*
*/
export async function getOverrideKeys(companyCode: string): Promise<ApiResponse<LangKey[]>> {
try {
const response = await apiClient.get(`/multilang/keys/overrides/${companyCode}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "OVERRIDE_KEYS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function saveLangTexts(
keyId: number,
texts: Array<{ langCode: string; langText: string }>
): Promise<ApiResponse<string>> {
try {
const response = await apiClient.post(`/multilang/keys/${keyId}/texts`, { texts });
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "TEXTS_SAVE_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function deleteLangKey(keyId: number): Promise<ApiResponse<string>> {
try {
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_DELETE_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function toggleLangKey(keyId: number): Promise<ApiResponse<string>> {
try {
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_TOGGLE_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 화면 라벨 다국어 자동 생성 API
// =====================================================
export interface ScreenLabelKeyResult {
componentId: string;
keyId: number;
langKey: string;
}
export interface GenerateScreenLabelKeysRequest {
screenId: number;
menuObjId?: string;
labels: Array<{
componentId: string;
label: string;
type?: string;
}>;
}
/**
*
*/
export async function generateScreenLabelKeys(
params: GenerateScreenLabelKeysRequest
): Promise<ApiResponse<ScreenLabelKeyResult[]>> {
try {
const response = await apiClient.post("/multilang/screen-labels", params);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error.message,
},
};
}
}

View File

@ -105,6 +105,18 @@ export const screenApi = {
return response.data;
},
// 화면 수정 (이름, 설명 등)
updateScreen: async (
screenId: number,
data: {
screenName?: string;
description?: string;
tableName?: string;
}
): Promise<void> => {
await apiClient.put(`/screen-management/screens/${screenId}`, data);
},
// 화면 삭제 (휴지통으로 이동)
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}`, {

View File

@ -0,0 +1,594 @@
/**
* API
* - (screen_groups)
* - - (screen_group_screens)
* - (screen_field_joins)
* - (screen_data_flows)
* - - (screen_table_relations)
*/
import { apiClient } from "./client";
// ============================================================
// 타입 정의
// ============================================================
export interface ScreenGroup {
id: number;
group_name: string;
group_code: string;
main_table_name?: string;
description?: string;
icon?: string;
display_order: number;
is_active: string;
company_code: string;
created_date?: string;
updated_date?: string;
writer?: string;
screen_count?: number;
screens?: ScreenGroupScreen[];
parent_group_id?: number | null; // 상위 그룹 ID
group_level?: number; // 그룹 레벨 (0: 대분류, 1: 중분류, 2: 소분류 ...)
hierarchy_path?: string; // 계층 경로
}
export interface ScreenGroupScreen {
id: number;
group_id: number;
screen_id: number;
screen_name?: string;
screen_role: string;
display_order: number;
is_default: string;
company_code: string;
}
export interface FieldJoin {
id: number;
screen_id: number;
layout_id?: number;
component_id?: string;
field_name?: string;
save_table: string;
save_column: string;
join_table: string;
join_column: string;
display_column: string;
join_type: string;
filter_condition?: string;
sort_column?: string;
sort_direction?: string;
is_active: string;
save_table_label?: string;
join_table_label?: string;
}
export interface DataFlow {
id: number;
group_id?: number;
source_screen_id: number;
source_action?: string;
target_screen_id: number;
target_action?: string;
data_mapping?: Record<string, any>;
flow_type: string;
flow_label?: string;
condition_expression?: string;
is_active: string;
source_screen_name?: string;
target_screen_name?: string;
group_name?: string;
}
export interface TableRelation {
id: number;
group_id?: number;
screen_id: number;
table_name: string;
relation_type: string;
crud_operations: string;
description?: string;
is_active: string;
screen_name?: string;
group_name?: string;
table_label?: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
total?: number;
page?: number;
size?: number;
totalPages?: number;
}
// ============================================================
// 화면 그룹 (screen_groups) API
// ============================================================
export async function getScreenGroups(params?: {
page?: number;
size?: number;
searchTerm?: string;
}): Promise<ApiResponse<ScreenGroup[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.size) queryParams.append("size", params.size.toString());
if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm);
const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getScreenGroup(id: number): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.get(`/screen-groups/groups/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createScreenGroup(data: Partial<ScreenGroup>): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.post("/screen-groups/groups", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateScreenGroup(id: number, data: Partial<ScreenGroup>): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.put(`/screen-groups/groups/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteScreenGroup(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/groups/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면-그룹 연결 (screen_group_screens) API
// ============================================================
export async function addScreenToGroup(data: {
group_id: number;
screen_id: number;
screen_role?: string;
display_order?: number;
is_default?: string;
}): Promise<ApiResponse<ScreenGroupScreen>> {
try {
const response = await apiClient.post("/screen-groups/group-screens", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateScreenInGroup(id: number, data: {
screen_role?: string;
display_order?: number;
is_default?: string;
}): Promise<ApiResponse<ScreenGroupScreen>> {
try {
const response = await apiClient.put(`/screen-groups/group-screens/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function removeScreenFromGroup(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/group-screens/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 필드 조인 (screen_field_joins) API
// ============================================================
export async function getFieldJoins(screenId?: number): Promise<ApiResponse<FieldJoin[]>> {
try {
const queryParams = screenId ? `?screen_id=${screenId}` : "";
const response = await apiClient.get(`/screen-groups/field-joins${queryParams}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createFieldJoin(data: Partial<FieldJoin>): Promise<ApiResponse<FieldJoin>> {
try {
const response = await apiClient.post("/screen-groups/field-joins", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateFieldJoin(id: number, data: Partial<FieldJoin>): Promise<ApiResponse<FieldJoin>> {
try {
const response = await apiClient.put(`/screen-groups/field-joins/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteFieldJoin(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/field-joins/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 데이터 흐름 (screen_data_flows) API
// ============================================================
export async function getDataFlows(params?: { groupId?: number; sourceScreenId?: number }): Promise<ApiResponse<DataFlow[]>> {
try {
const queryParts: string[] = [];
if (params?.groupId) {
queryParts.push(`group_id=${params.groupId}`);
}
if (params?.sourceScreenId) {
queryParts.push(`source_screen_id=${params.sourceScreenId}`);
}
const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
const response = await apiClient.get(`/screen-groups/data-flows${queryString}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createDataFlow(data: Partial<DataFlow>): Promise<ApiResponse<DataFlow>> {
try {
const response = await apiClient.post("/screen-groups/data-flows", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateDataFlow(id: number, data: Partial<DataFlow>): Promise<ApiResponse<DataFlow>> {
try {
const response = await apiClient.put(`/screen-groups/data-flows/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteDataFlow(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/data-flows/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면-테이블 관계 (screen_table_relations) API
// ============================================================
export async function getTableRelations(params?: {
screen_id?: number;
group_id?: number;
}): Promise<ApiResponse<TableRelation[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.screen_id) queryParams.append("screen_id", params.screen_id.toString());
if (params?.group_id) queryParams.append("group_id", params.group_id.toString());
const response = await apiClient.get(`/screen-groups/table-relations?${queryParams.toString()}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createTableRelation(data: Partial<TableRelation>): Promise<ApiResponse<TableRelation>> {
try {
const response = await apiClient.post("/screen-groups/table-relations", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateTableRelation(id: number, data: Partial<TableRelation>): Promise<ApiResponse<TableRelation>> {
try {
const response = await apiClient.put(`/screen-groups/table-relations/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteTableRelation(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/table-relations/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면 레이아웃 요약 (미리보기용) API
// ============================================================
// 레이아웃 아이템 (미니어처 렌더링용)
export interface LayoutItem {
x: number;
y: number;
width: number;
height: number;
componentKind: string; // 정확한 컴포넌트 종류 (table-list, button-primary 등)
widgetType: string; // 일반적인 위젯 타입 (button, text 등)
label?: string;
bindField?: string; // 바인딩된 필드명 (컬럼명)
usedColumns?: string[]; // 이 컴포넌트에서 사용하는 컬럼 목록
joinColumns?: string[]; // 이 컴포넌트에서 조인 컬럼 목록 (isEntityJoin=true)
}
export interface ScreenLayoutSummary {
screenId: number;
screenType: 'form' | 'grid' | 'dashboard' | 'action';
widgetCounts: Record<string, number>;
totalComponents: number;
// 미니어처 렌더링용 레이아웃 데이터
layoutItems: LayoutItem[];
canvasWidth: number;
canvasHeight: number;
}
// 단일 화면 레이아웃 요약 조회
export async function getScreenLayoutSummary(screenId: number): Promise<ApiResponse<ScreenLayoutSummary>> {
try {
const response = await apiClient.get(`/screen-groups/layout-summary/${screenId}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 여러 화면 레이아웃 요약 일괄 조회
export async function getMultipleScreenLayoutSummary(
screenIds: number[]
): Promise<ApiResponse<Record<number, ScreenLayoutSummary>>> {
try {
const response = await apiClient.post("/screen-groups/layout-summary/batch", { screenIds });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 필드 매핑 정보 타입
export interface FieldMappingInfo {
sourceTable?: string; // 연관 테이블명 (parentDataMapping에서 사용)
sourceField: string;
targetField: string;
sourceDisplayName?: string; // 메인 테이블 한글 컬럼명
targetDisplayName?: string; // 서브 테이블 한글 컬럼명
}
// 서브 테이블 정보 타입
export interface SubTableInfo {
tableName: string;
tableLabel?: string; // 테이블 한글명
componentType: string;
relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation';
fieldMappings?: FieldMappingInfo[];
filterColumns?: string[]; // 필터링에 사용되는 컬럼 목록
// rightPanelRelation에서 추가 정보 (관계 유형 추론용)
originalRelationType?: 'join' | 'detail'; // 원본 relation.type
foreignKey?: string; // 디테일 테이블의 FK 컬럼
leftColumn?: string; // 마스터 테이블의 선택 기준 컬럼
// rightPanel.columns에서 외부 테이블 참조 정보
joinedTables?: string[]; // 참조하는 외부 테이블들 (예: ['customer_mng'])
joinColumns?: string[]; // 외부 테이블과 조인하는 FK 컬럼들 (예: ['customer_id'])
joinColumnRefs?: Array<{ // FK 컬럼 참조 정보 (어떤 테이블.컬럼에서 오는지)
column: string; // FK 컬럼명 (예: 'customer_id')
columnLabel: string; // FK 컬럼 한글명 (예: '거래처 ID')
refTable: string; // 참조 테이블 (예: 'customer_mng')
refTableLabel: string; // 참조 테이블 한글명 (예: '거래처 관리')
refColumn: string; // 참조 컬럼 (예: 'customer_code')
}>;
}
// 시각적 관계 유형 (시각화에서 사용)
export type VisualRelationType = 'filter' | 'hierarchy' | 'lookup' | 'mapping' | 'join';
// 관계 유형 추론 함수
export function inferVisualRelationType(subTable: SubTableInfo): VisualRelationType {
// 1. split-panel-layout의 rightPanel.relation
if (subTable.relationType === 'rightPanelRelation') {
// 원본 relation.type 기반 구분
if (subTable.originalRelationType === 'detail') {
return 'hierarchy'; // 부모-자식 계층 구조 (같은 테이블 자기 참조)
}
return 'filter'; // 마스터-디테일 필터링
}
// 2. selected-items-detail-input의 parentDataMapping
// parentDataMapping은 FK 관계를 정의하므로 조인으로 분류
if (subTable.relationType === 'parentMapping') {
return 'join'; // FK 조인 (sourceTable.sourceField → targetTable.targetField)
}
// 3. column_labels.reference_table
if (subTable.relationType === 'reference') {
return 'join'; // 실제 엔티티 조인 (LEFT JOIN 등)
}
// 4. autocomplete, entity-search
if (subTable.relationType === 'lookup') {
return 'lookup'; // 코드→명칭 변환
}
// 5. 기타 (source, join 등)
return 'join';
}
// 저장 테이블 정보 타입
export interface SaveTableInfo {
tableName: string;
saveType: 'save' | 'edit' | 'delete' | 'transferData';
componentType: string;
isMainTable: boolean;
mappingRules?: Array<{
sourceField: string;
targetField: string;
transform?: string;
}>;
}
export interface ScreenSubTablesData {
screenId: number;
screenName: string;
mainTable: string;
subTables: SubTableInfo[];
saveTables?: SaveTableInfo[]; // 저장 대상 테이블 목록
}
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)
export async function getScreenSubTables(
screenIds: number[]
): Promise<ApiResponse<Record<number, ScreenSubTablesData>>> {
try {
const response = await apiClient.post("/screen-groups/sub-tables/batch", { screenIds });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 메뉴-화면그룹 동기화 API
// ============================================================
export interface SyncDetail {
action: 'created' | 'linked' | 'skipped' | 'error';
sourceName: string;
sourceId: number | string;
targetId?: number | string;
reason?: string;
}
export interface SyncResult {
success: boolean;
created: number;
linked: number;
skipped: number;
errors: string[];
details: SyncDetail[];
}
export interface SyncStatus {
screenGroups: { total: number; linked: number; unlinked: number };
menuItems: { total: number; linked: number; unlinked: number };
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
}
// 동기화 상태 조회
export async function getMenuScreenSyncStatus(
targetCompanyCode?: string
): Promise<ApiResponse<SyncStatus>> {
try {
const queryParams = targetCompanyCode ? `?targetCompanyCode=${targetCompanyCode}` : '';
const response = await apiClient.get(`/screen-groups/sync/status${queryParams}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 화면관리 → 메뉴 동기화
export async function syncScreenGroupsToMenu(
targetCompanyCode?: string
): Promise<ApiResponse<SyncResult>> {
try {
const response = await apiClient.post("/screen-groups/sync/screen-to-menu", { targetCompanyCode });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 메뉴 → 화면관리 동기화
export async function syncMenuToScreenGroups(
targetCompanyCode?: string
): Promise<ApiResponse<SyncResult>> {
try {
const response = await apiClient.post("/screen-groups/sync/menu-to-screen", { targetCompanyCode });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 전체 동기화 결과 타입
export interface AllCompaniesSyncResult {
totalCompanies: number;
successCount: number;
failedCount: number;
totalCreated: number;
totalLinked: number;
details: Array<{
companyCode: string;
companyName: string;
direction: 'screens-to-menus' | 'menus-to-screens';
created: number;
linked: number;
skipped: number;
success: boolean;
error?: string;
}>;
}
// 전체 회사 동기화 (최고 관리자만)
export async function syncAllCompanies(): Promise<ApiResponse<AllCompaniesSyncResult>> {
try {
const response = await apiClient.post("/screen-groups/sync/all");
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}

View File

@ -13,7 +13,7 @@ export interface ColumnTypeInfo {
dataType: string;
dbType: string;
webType: string;
inputType?: "direct" | "auto";
inputType?: string; // text, number, entity, code, select, date, checkbox 등
detailSettings: string;
description?: string;
isNullable: string;
@ -39,11 +39,11 @@ export interface TableInfo {
columnCount: number;
}
// 컬럼 설정 타입
// 컬럼 설정 타입 (백엔드 API와 동일한 필드명 사용)
export interface ColumnSettings {
columnName?: string;
columnLabel: string;
webType: string;
inputType: string; // 백엔드에서 inputType으로 받음
detailSettings: string;
codeCategory: string;
codeValue: string;

View File

@ -281,10 +281,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id;
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue;
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
// EditModal에서 전달된 groupedData가 있으면 우선 사용
if (componentType === "modal-repeater-table" ||
componentType === "repeat-screen-modal" ||
componentType === "selected-items-detail-input") {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
} else {
currentValue = formData?.[fieldName] || "";

View File

@ -299,20 +299,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
useEffect(() => {
const newData = splitPanelContext?.selectedLeftData ?? null;
setTrackedSelectedLeftData(newData);
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
// label: component.label,
// hasData: !!newData,
// dataKeys: newData ? Object.keys(newData) : [],
// });
}, [splitPanelContext?.selectedLeftData, component.label]);
// modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => {
const actionConfig = component.componentConfig?.action;
@ -371,8 +357,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
// SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
// SplitPanelContext에서 확인
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
if (!hasSelection) {
hasSelection = true;
selectionCount = 1;
@ -411,7 +397,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
selectionCount,
selectionSource,
hasSplitPanelContext: !!splitPanelContext,
trackedSelectedLeftData: trackedSelectedLeftData,
selectedLeftData: splitPanelContext?.selectedLeftData,
selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length,
@ -443,7 +429,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
component.label,
selectedRows,
selectedRowsData,
trackedSelectedLeftData,
splitPanelContext?.selectedLeftData,
flowSelectedData,
splitPanelContext,
modalStoreData,
@ -509,15 +495,50 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
} as ButtonPrimaryConfig;
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const getLabelColor = () => {
if (isDeleteAction()) {
return component.style?.labelColor || "#ef4444"; // 빨간색 기본값 (Tailwind red-500)
// 🎨 동적 색상 설정 (webTypeConfig 우선, 레거시 style.labelColor 지원)
const getButtonBackgroundColor = () => {
// 1순위: webTypeConfig.backgroundColor (화면설정 모달에서 저장)
if (component.webTypeConfig?.backgroundColor) {
return component.webTypeConfig.backgroundColor;
}
return component.style?.labelColor || "#212121"; // 검은색 기본값 (shadcn/ui primary)
// 2순위: componentConfig.backgroundColor
if (componentConfig.backgroundColor) {
return componentConfig.backgroundColor;
}
// 3순위: style.backgroundColor
if (component.style?.backgroundColor) {
return component.style.backgroundColor;
}
// 4순위: style.labelColor (레거시)
if (component.style?.labelColor) {
return component.style.labelColor;
}
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑
if (isDeleteAction()) {
return "#ef4444"; // 빨간색 (Tailwind red-500)
}
return "#3b82f6"; // 파란색 (Tailwind blue-500)
};
const buttonColor = getLabelColor();
const getButtonTextColor = () => {
// 1순위: webTypeConfig.textColor (화면설정 모달에서 저장)
if (component.webTypeConfig?.textColor) {
return component.webTypeConfig.textColor;
}
// 2순위: componentConfig.textColor
if (componentConfig.textColor) {
return componentConfig.textColor;
}
// 3순위: style.color
if (component.style?.color) {
return component.style.color;
}
// 기본값: 흰색
return "#ffffff";
};
const buttonColor = getButtonBackgroundColor();
const buttonTextColor = getButtonTextColor();
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
const processedConfig = { ...componentConfig };
@ -1129,7 +1150,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} : undefined,
} as ButtonActionContext;
// 확인이 필요한 액션인지 확인
// 확인이 필요한 액션인지 확인 (save/delete만 확인 다이얼로그 표시)
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
// 확인 다이얼로그 표시
setPendingAction({
@ -1265,8 +1286,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
minHeight: "40px",
border: "none",
borderRadius: "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
color: finalDisabled ? "#9ca3af" : "white",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
fontWeight: "600",

View File

@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
// 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
// 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
/**
*
*/

View File

@ -180,8 +180,11 @@ export function ModalRepeaterTableComponent({
filterCondition: propFilterCondition,
companyCode: propCompanyCode,
// 🆕 그룹 데이터 (EditModal에서 전달, 같은 그룹의 여러 품목)
groupedData,
...props
}: ModalRepeaterTableComponentProps) {
}: ModalRepeaterTableComponentProps & { groupedData?: Record<string, any>[] }) {
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
@ -208,9 +211,16 @@ export function ModalRepeaterTableComponent({
// 모달 필터 설정
const modalFilters = componentConfig?.modalFilters || [];
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
// ✅ value는 groupedData 우선, 없으면 formData[columnName], 없으면 prop 사용
const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 groupedData가 전달되면 (EditModal에서 그룹 조회 결과) 우선 사용
const externalValue = (() => {
if (groupedData && groupedData.length > 0) {
return groupedData;
}
return (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
})();
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
const isEmptyRow = (item: any): boolean => {

View File

@ -1,12 +1,73 @@
"use client";
import React from "react";
import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { PivotGridComponent } from "./PivotGridComponent";
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
import { PivotFieldConfig } from "./types";
import { dataApi } from "@/lib/api/data";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
// ==================== 에러 경계 ====================
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class PivotGridErrorBoundary extends Component<
{ children: ReactNode; onReset?: () => void },
ErrorBoundaryState
> {
constructor(props: { children: ReactNode; onReset?: () => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("🔴 [PivotGrid] 렌더링 에러:", error);
console.error("🔴 [PivotGrid] 에러 정보:", errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: undefined });
this.props.onReset?.();
};
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center border border-destructive/50 rounded-lg bg-destructive/5">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<h3 className="text-sm font-medium text-destructive mb-1">
</h3>
<p className="text-xs text-muted-foreground mb-3 max-w-md">
{this.state.error?.message || "알 수 없는 오류가 발생했습니다."}
</p>
<Button
variant="outline"
size="sm"
onClick={this.handleReset}
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
);
}
return this.props.children;
}
}
// ==================== 샘플 데이터 (미리보기용) ====================
@ -95,43 +156,63 @@ const PivotGridWrapper: React.FC<any> = (props) => {
const configFields = componentConfig.fields || props.fields;
const configData = props.data;
// 디버깅 로그
console.log("🔷 PivotGridWrapper props:", {
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive,
hasComponentConfig: !!props.componentConfig,
hasConfig: !!props.config,
hasData: !!configData,
dataLength: configData?.length,
hasFields: !!configFields,
fieldsLength: configFields?.length,
});
// 🆕 테이블에서 데이터 자동 로딩
const [loadedData, setLoadedData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadTableData = async () => {
const tableName = componentConfig.dataSource?.tableName;
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음
if (configData || !tableName || props.isDesignMode) {
return;
}
setIsLoading(true);
try {
const response = await dataApi.getTableData(tableName, {
page: 1,
size: 10000, // 피벗 분석용 대량 데이터
});
// dataApi.getTableData는 { data, total, page, size, totalPages } 구조
if (response.data && Array.isArray(response.data)) {
setLoadedData(response.data);
} else {
console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음");
setLoadedData([]);
}
} catch (error) {
console.error("❌ [PivotGrid] 데이터 로딩 에러:", error);
} finally {
setIsLoading(false);
}
};
loadTableData();
}, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]);
// 디자인 모드 판단:
// 1. isDesignMode === true
// 2. isInteractive === false (편집 모드)
// 3. 데이터가 없는 경우
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
const actualData = configData || loadedData;
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
const usePreviewData = isDesignMode || !hasValidData;
const usePreviewData = isDesignMode || (!hasValidData && !isLoading);
// 최종 데이터/필드 결정
const finalData = usePreviewData ? SAMPLE_DATA : configData;
const finalData = usePreviewData ? SAMPLE_DATA : actualData;
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
const finalTitle = usePreviewData
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
: (componentConfig.title || props.title);
console.log("🔷 PivotGridWrapper final:", {
isDesignMode,
usePreviewData,
finalDataLength: finalData?.length,
finalFieldsLength: finalFields?.length,
});
// 총계 설정
const totalsConfig = componentConfig.totals || props.totals || {
showRowGrandTotals: true,
@ -140,24 +221,39 @@ const PivotGridWrapper: React.FC<any> = (props) => {
showColumnTotals: true,
};
// 🆕 로딩 중 표시
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
<div className="text-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
);
}
// 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함
return (
<PivotGridComponent
title={finalTitle}
data={finalData}
fields={finalFields}
totals={totalsConfig}
style={componentConfig.style || props.style}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height={componentConfig.height || props.height || "400px"}
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}
onCellDoubleClick={props.onCellDoubleClick}
onFieldDrop={props.onFieldDrop}
onExpandChange={props.onExpandChange}
/>
<PivotGridErrorBoundary>
<PivotGridComponent
title={finalTitle}
data={finalData}
fields={finalFields}
totals={totalsConfig}
style={componentConfig.style || props.style}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height="100%"
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}
onCellDoubleClick={props.onCellDoubleClick}
onFieldDrop={props.onFieldDrop}
onExpandChange={props.onExpandChange}
/>
</PivotGridErrorBoundary>
);
};
@ -224,18 +320,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
const configFields = componentConfig.fields || props.fields;
const configData = props.data;
// 디버깅 로그
console.log("🔷 PivotGridRenderer props:", {
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive,
hasComponentConfig: !!props.componentConfig,
hasConfig: !!props.config,
hasData: !!configData,
dataLength: configData?.length,
hasFields: !!configFields,
fieldsLength: configFields?.length,
});
// 디자인 모드 판단:
// 1. isDesignMode === true
// 2. isInteractive === false (편집 모드)
@ -254,13 +338,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
: (componentConfig.title || props.title);
console.log("🔷 PivotGridRenderer final:", {
isDesignMode,
usePreviewData,
finalDataLength: finalData?.length,
finalFieldsLength: finalFields?.length,
});
// 총계 설정
const totalsConfig = componentConfig.totals || props.totals || {
showRowGrandTotals: true,
@ -279,7 +356,7 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height={componentConfig.height || props.height || "400px"}
height="100%"
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}

View File

@ -0,0 +1,213 @@
"use client";
/**
* PivotGrid
* , , /
*/
import React from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
ArrowUpAZ,
ArrowDownAZ,
Filter,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
BarChart3,
} from "lucide-react";
import { PivotFieldConfig, AggregationType } from "../types";
interface PivotContextMenuProps {
children: React.ReactNode;
// 현재 컨텍스트 정보
cellType: "header" | "data" | "rowHeader" | "columnHeader";
field?: PivotFieldConfig;
rowPath?: string[];
columnPath?: string[];
value?: any;
// 콜백
onSort?: (field: string, direction: "asc" | "desc") => void;
onFilter?: (field: string) => void;
onExpand?: (path: string[]) => void;
onCollapse?: (path: string[]) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
onCopy?: (value: any) => void;
onHideField?: (field: string) => void;
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
}
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
children,
cellType,
field,
rowPath,
columnPath,
value,
onSort,
onFilter,
onExpand,
onCollapse,
onExpandAll,
onCollapseAll,
onCopy,
onHideField,
onChangeSummary,
onDrillDown,
}) => {
const handleCopy = () => {
if (value !== undefined && value !== null) {
navigator.clipboard.writeText(String(value));
onCopy?.(value);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 정렬 옵션 (헤더에서만) */}
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
<ArrowDownAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 확장/축소 옵션 */}
{(cellType === "rowHeader" || cellType === "columnHeader") && (
<>
{rowPath && rowPath.length > 0 && (
<>
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpandAll}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={onCollapseAll}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필터 옵션 */}
{field && onFilter && (
<>
<ContextMenuItem onClick={() => onFilter(field.field)}>
<Filter className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 집계 함수 변경 (데이터 필드에서만) */}
{cellType === "data" && field && onChangeSummary && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<BarChart3 className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "sum")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "count")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "avg")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "min")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "max")}
>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 드릴다운 (데이터 셀에서만) */}
{cellType === "data" && rowPath && columnPath && onDrillDown && (
<>
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
<Eye className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필드 숨기기 */}
{field && onHideField && (
<ContextMenuItem onClick={() => onHideField(field.field)}>
<EyeOff className="mr-2 h-4 w-4" />
</ContextMenuItem>
)}
{/* 복사 */}
<ContextMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export default PivotContextMenu;

View File

@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
];
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
{ value: "none", label: "그룹 없음" },
{ value: "year", label: "년" },
{ value: "quarter", label: "분기" },
{ value: "month", label: "월" },
{ value: "week", label: "주" },
{ value: "day", label: "일" },
];
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
string: <Type className="h-3.5 w-3.5" />,
number: <Hash className="h-3.5 w-3.5" />,
@ -258,11 +267,9 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
const existingConfig = selectedFields.find((f) => f.field === field.field);
if (area === "none") {
// 필드 제거 또는 숨기기
// 필드 완전 제거 (visible: false 대신 배열에서 제거)
if (existingConfig) {
const newFields = selectedFields.map((f) =>
f.field === field.field ? { ...f, visible: false } : f
);
const newFields = selectedFields.filter((f) => f.field !== field.field);
onFieldsChange(newFields);
}
} else {
@ -392,7 +399,7 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
</div>
{/* 필드 목록 */}
<ScrollArea className="flex-1 -mx-6 px-6">
<ScrollArea className="flex-1 -mx-6 px-6 max-h-[40vh] overflow-y-auto">
<div className="space-y-2 py-2">
{filteredFields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">

View File

@ -2,7 +2,7 @@
/**
* FieldPanel
* (, , , )
* (, , )
*
*/
@ -25,6 +25,7 @@ import {
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { PivotFieldConfig, PivotAreaType } from "../types";
@ -244,22 +245,31 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
// 🆕 드롭 가능 영역 설정
const { setNodeRef, isOver: isOverDroppable } = useDroppable({
id: area, // "filter", "column", "row", "data"
});
const finalIsOver = isOver || isOverDroppable;
return (
<div
ref={setNodeRef}
className={cn(
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
"transition-colors duration-200",
"flex-1 min-h-[60px] rounded border-2 border-dashed p-2",
"transition-all duration-200",
config.color,
isOver && "border-primary bg-primary/5"
finalIsOver && "border-primary bg-primary/10 scale-[1.02]",
areaFields.length === 0 && "border-2" // 빈 영역일 때 테두리 강조
)}
data-area={area}
>
{/* 영역 헤더 */}
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-1 mb-1.5 text-xs font-semibold text-muted-foreground">
{icon}
<span>{title}</span>
{areaFields.length > 0 && (
<span className="text-[10px] bg-muted px-1 rounded">
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded">
{areaFields.length}
</span>
)}
@ -267,11 +277,16 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
{/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
<div className="flex flex-wrap gap-1 min-h-[28px] relative">
{areaFields.length === 0 ? (
<span className="text-xs text-muted-foreground/50 italic">
</span>
<div
className="flex items-center justify-center w-full py-1 pointer-events-none"
style={{ pointerEvents: 'none' }}
>
<span className="text-xs text-muted-foreground/70 italic font-medium">
</span>
</div>
) : (
areaFields.map((field) => (
<SortableFieldChip
@ -339,8 +354,16 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
return;
}
// 드롭 영역 감지
// 드롭 영역 감지 (영역 자체의 ID를 우선 확인)
const overId = over.id as string;
// 1. overId가 영역 자체인 경우 (filter, column, row, data)
if (["filter", "column", "row", "data"].includes(overId)) {
setOverArea(overId as PivotAreaType);
return;
}
// 2. overId가 필드인 경우 (예: row-part_name)
const targetArea = overId.split("-")[0] as PivotAreaType;
if (["filter", "column", "row", "data"].includes(targetArea)) {
setOverArea(targetArea);
@ -350,10 +373,13 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
// 드래그 종료
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장
setActiveId(null);
setOverArea(null);
if (!over) return;
if (!over) {
return;
}
const activeId = active.id as string;
const overId = over.id as string;
@ -363,7 +389,16 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
PivotAreaType,
string
];
const [targetArea] = overId.split("-") as [PivotAreaType, string];
// targetArea 결정: handleDragOver에서 감지한 영역 우선 사용
let targetArea: PivotAreaType;
if (currentOverArea) {
targetArea = currentOverArea;
} else if (["filter", "column", "row", "data"].includes(overId)) {
targetArea = overId as PivotAreaType;
} else {
targetArea = overId.split("-")[0] as PivotAreaType;
}
// 같은 영역 내 정렬
if (sourceArea === targetArea) {
@ -406,6 +441,7 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
}
return f;
});
onFieldsChange(newFields);
}
};
@ -443,16 +479,42 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
? fields.find((f) => `${f.area}-${f.field}` === activeId)
: null;
// 각 영역의 필드 수 계산
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
if (collapsed) {
return (
<div className="border-b border-border px-3 py-2">
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{filterCount > 0 && (
<span className="flex items-center gap-1">
<Filter className="h-3 w-3" />
{filterCount}
</span>
)}
<span className="flex items-center gap-1">
<Columns className="h-3 w-3" />
{columnCount}
</span>
<span className="flex items-center gap-1">
<Rows className="h-3 w-3" />
{rowCount}
</span>
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{dataCount}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs"
className="text-xs h-6 px-2"
>
</Button>
</div>
);
@ -466,9 +528,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="border-b border-border bg-muted/20 p-3">
{/* 2x2 그리드로 영역 배치 */}
<div className="grid grid-cols-2 gap-2">
<div className="border-b border-border bg-muted/20 p-2">
{/* 4개 영역 배치: 2x2 그리드 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 필터 영역 */}
<DroppableArea
area="filter"
@ -516,12 +578,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
{/* 접기 버튼 */}
{onToggleCollapse && (
<div className="flex justify-center mt-2">
<div className="flex justify-center mt-1.5">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-6"
className="text-xs h-5 px-2"
>
</Button>

View File

@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart";
export { PivotContextMenu } from "./ContextMenu";

View File

@ -51,14 +51,18 @@ export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollRe
// 보이는 아이템 수
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 시작/끝 인덱스 계산
// 시작/끝 인덱스 계산 (음수 방지)
const { startIndex, endIndex } = useMemo(() => {
// itemCount가 0이면 빈 배열
if (itemCount === 0) {
return { startIndex: 0, endIndex: -1 };
}
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const end = Math.min(
itemCount - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
return { startIndex: start, endIndex: end };
return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
// 전체 높이

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