Compare commits

...

35 Commits

Author SHA1 Message Date
kjs bb49073bf7 feat: 테이블 리스트에서 카테고리 다중 값 배지 표시 지원
문제:
- 테이블에서 'CATEGORY_218152,CATEGORY_205381' 같은 다중 값이
- 배지로 표시되지 않고 코드값 그대로 보임

원인:
- formatCellValue의 카테고리 렌더링이 단일 값만 처리
- 콤마로 구분된 다중 값 파싱 로직 없음

해결:
1. 콤마 구분자 감지 및 값 배열로 분리
2. 단일 값: 기존 로직 유지 (단일 배지)
3. 다중 값: flex-wrap gap-1로 여러 배지 렌더링
4. 각 배지는 매핑된 라벨과 색상 사용

결과:
 다중선택 저장된 데이터가 테이블에서 여러 배지로 표시됨
 각 배지에 올바른 색상과 라벨 적용
 단일 값도 기존처럼 정상 작동
2025-11-21 10:03:26 +09:00
kjs f4d27f51a3 fix: 카테고리 값 매핑을 올바른 속성명으로 수정
문제:
- value: undefined, label: undefined
- 잘못된 속성명 사용 (categoryValue, categoryLabel)

원인:
- API 응답 실제 구조:
  - valueCode: 'CATEGORY_154396'
  - valueLabel: '대기'

해결:
- v.categoryValue → v.valueCode
- v.categoryLabel → v.valueLabel

이제 다중선택 카테고리 select가 완벽히 작동합니다:
 다중선택 모드 활성화
 카테고리 옵션 로딩
 라벨 정상 표시
 콤마 구분자로 저장
2025-11-21 09:40:24 +09:00
kjs 114a807d79 debug: 카테고리 API 응답 원본 데이터 구조 확인
문제:
- value: undefined, label: undefined로 나옴
- v.categoryValue, v.categoryLabel이 존재하지 않음

디버깅:
- API 응답의 첫 번째 항목 전체 출력
- 객체의 모든 키 목록 출력
- 여러 가능한 속성명 시도:
  - category_value / categoryValue / value
  - category_label / categoryLabel / label

다음 단계:
- 콘솔에서 원본 데이터 구조 확인
- 실제 속성명에 맞게 매핑 수정
2025-11-21 09:39:09 +09:00
kjs 4928c54985 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-11-21 09:29:23 +09:00
dohyeons 86c671026b test entity search페이지 임시 비활성화 2025-11-21 04:05:42 +09:00
dohyeons 4cbfa8d70d 임시비활성화 2025-11-21 03:58:02 +09:00
dohyeons ef08c3fd7a 타입에러 수정 2025-11-21 03:50:45 +09:00
dohyeons 96401634b2 타입 정의 삭제 2025-11-21 03:45:51 +09:00
dohyeons fda7614d48 빌드에러해결 2025-11-21 03:40:41 +09:00
hyeonsu 25c36167c0 Merge pull request '대시보드 수정' (#216) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/216
2025-11-21 03:34:27 +09:00
dohyeons 205d062f4a 수정.. 2025-11-21 03:33:49 +09:00
dohyeons 0450390b2a 배치 대략적인 완료 2025-11-21 02:25:25 +09:00
kjs fc16f27640 feat: select-basic에 카테고리(category) 타입 옵션 로딩 추가
문제:
- select-basic이 webType='category'일 때 옵션이 안 보임
- CATEGORY_218152 같은 코드값만 표시됨
- 체크박스는 보이지만 라벨이 비어있음

원인:
- select-basic은 useCodeOptions만 사용 (code 타입용)
- category 타입은 getCategoryValues API 필요

해결:
1. categoryOptions 상태 추가
2. webType === 'category'일 때 getCategoryValues 호출
3. getAllOptions에 categoryOptions 포함
4. 로딩 상태에 isLoadingCategories 추가

디버깅:
- 카테고리 로딩 시작/완료 로그
- API 응답 로그
- 최종 allOptions 로그 추가

다음 단계:
- 콘솔에서 categoryOptions가 제대로 로드되는지 확인
2025-11-20 18:35:48 +09:00
kjs dd568b7235 fix: select-basic 카테고리 조건 로직 수정
문제:
- 이전 커밋에서 로직을 반대로 작성
- componentType !== 'select-basic'로 했지만
- componentType === 'select-basic'일 때 건너뛰어야 함

수정:
- componentType === 'select-basic'이면 통과 (아무것도 안 함)
- 그 외 카테고리는 CategorySelectComponent 사용

로직:
if (category && componentType === 'select-basic') {
  // 통과 - ComponentRegistry로 진행
} else if (category) {
  // CategorySelectComponent 사용
}
2025-11-20 18:31:50 +09:00
kjs 87fbf5b858 fix: select-basic 컴포넌트가 CategorySelectComponent에 가로채지는 문제 해결
문제:
- componentType이 'select-basic'이지만 webType이 'category'일 때
- DynamicComponentRenderer가 무조건 CategorySelectComponent 사용
- select-basic의 multiple 설정이 무시됨

원인:
- 152줄에서 webType === 'category' 조건만 체크
- componentType을 확인하지 않아 select-basic도 가로챔

해결:
- componentType !== 'select-basic' 조건 추가
- select-basic은 카테고리 조건을 건너뛰고 ComponentRegistry로 진행
- 다중선택 등 select-basic의 고급 기능 사용 가능

변경사항:
- DynamicComponentRenderer.tsx 152줄
- 카테고리 조건에 componentType 체크 추가
2025-11-20 18:31:24 +09:00
kjs 9b65c1cbff debug: DynamicComponentRenderer에서 select-basic 조회 로그 추가
문제:
- SelectBasicComponent 렌더링 로그가 전혀 안 보임
- select-basic이 ComponentRegistry에 등록되었는지 확인 필요

디버깅:
- componentType이 'select-basic'일 때 조회 결과 로그
- found: true/false로 등록 여부 확인
- componentConfig 값도 함께 출력

예상 결과:
- found: false면 등록 실패
- found: true면 다른 문제 (렌더링 과정에서 문제)
2025-11-20 18:29:29 +09:00
kjs f765ac4a47 debug: SelectBasicComponent 렌더링 확인용 로그 추가
문제:
- 다중선택 설정했지만 UI에 반영 안됨
- 디버깅 로그가 콘솔에 전혀 안 보임

원인 추정:
- SelectBasicComponent가 아예 렌더링 안되고 있을 가능성
- 또는 다른 select 컴포넌트가 대신 렌더링될 가능성

테스트:
- 최상단에 눈에 띄는 로그 (🚨🚨🚨) 추가
- componentId, componentType, columnName, multiple 값 출력
- 이 로그가 안 보이면 다른 컴포넌트가 렌더링되는 것
2025-11-20 18:26:19 +09:00
kjs f6c96d168b debug: select-basic 다중선택 디버깅 로그 강화
더 명확한 로그 출력:
- 단계별로 구분된 로그
- 각 props 출처별로 명확히 표시
- 최종 isMultiple 값 강조
- 활성화/비활성화 상태 명확히 표시

사용자는 브라우저 콘솔에서 다음을 확인:
1. '🔍 [SelectBasicComponent] ========== 다중선택 디버깅 ==========' 로그 찾기
2. '최종 isMultiple 값' 확인
3. 각 props 출처의 multiple 값 확인
4. / 상태 메시지 확인
2025-11-20 18:23:29 +09:00
kjs 6ea9001a50 fix: select-basic 다중선택 옵션이 실제 화면에 반영되지 않는 문제 해결
문제:
- 설정 패널에서 '다중 선택' 체크했지만 실제 화면에서 작동하지 않음
- componentConfig.multiple이 저장되었지만 컴포넌트에서 인식 못함

원인:
- DynamicComponentRenderer에서 componentConfig를 spread하여 props로 전달
- 하지만 config.multiple만 체크하고 props.multiple를 체크하지 않음

해결:
- isMultiple 변수 추가: props.multiple > config.multiple 우선순위
- 모든 다중선택 로직에서 isMultiple 사용하도록 수정
- 디버깅 로그 추가하여 각 값의 출처 확인

변경사항:
- isMultiple = props.multiple ?? config.multiple ?? false
- 초기화, 업데이트, 렌더링 로직에 isMultiple 적용
- 상세 디버깅 로그로 문제 추적 가능
2025-11-20 18:21:09 +09:00
kjs c57e0218fe feat: select-basic 컴포넌트에 다중선택 기능 추가
기능:
- 설정 패널에 '다중 선택' 체크박스 추가
- multiple 옵션 활성화 시 다중선택 UI 렌더링
- 선택된 항목을 태그 형식으로 표시
- 각 태그에 X 버튼으로 개별 제거 가능
- 드롭다운에 체크박스 표시
- 콤마(,) 구분자로 값 저장/파싱

수정사항:
- SelectBasicConfigPanel: 다중 선택 체크박스 추가
- SelectBasicConfigPanel: config 병합 방식으로 변경 (다른 속성 보호)
- SelectBasicComponent: 초기값 콤마 구분자로 파싱
- SelectBasicComponent: 외부 value 변경 시 다중선택 배열 동기화
- SelectBasicComponent: 다중선택 UI 렌더링 로직 추가

사용법:
1. 설정 패널에서 '다중 선택' 체크
2. 드롭다운에서 여러 항목 선택
3. 선택된 항목이 태그로 표시되며 X로 제거 가능
4. 저장 시 '값1,값2,값3' 형식으로 저장
2025-11-20 18:17:08 +09:00
kjs 3219015a39 fix: 채번규칙 메뉴별 격리 문제 해결
문제: 영업관리 메뉴에서 생성한 채번규칙이 기준정보 메뉴에도 표시됨

원인:
- scope_type='table' 규칙을 조회할 때 menu_objid 필터링 없이 모든 규칙을 포함
- 'OR scope_type = 'table'' 조건이 다른 메뉴의 규칙도 반환

수정:
- scope_type='table' 규칙도 menu_objid로 필터링하도록 변경
- 'OR (scope_type = 'table' AND menu_objid = ANY(cd /Users/kimjuseok/ERP-node && git commit -m "fix: 채번규칙 메뉴별 격리 문제 해결

문제: 영업관리 메뉴에서 생성한 채번규칙이 기준정보 메뉴에도 표시됨

원인:
- scope_type='table' 규칙을 조회할 때 menu_objid 필터링 없이 모든 규칙을 포함
- 'OR scope_type = 'table'' 조건이 다른 메뉴의 규칙도 반환

수정:
- scope_type='table' 규칙도 menu_objid로 필터링하도록 변경
- 'OR (scope_type = 'table' AND menu_objid = ANY($1))' 조건으로 메뉴별 격리
- menu_objid IS NULL인 기존 규칙은 하위 호환성을 위해 유지

영향:
- 각 메뉴에서 생성한 채번규칙은 해당 메뉴(및 형제 메뉴)에서만 표시
- global 규칙은 여전히 모든 메뉴에서 표시
- 기존 데이터는 영향 없음 (menu_objid NULL 조건 유지)"))' 조건으로 메뉴별 격리
- menu_objid IS NULL인 기존 규칙은 하위 호환성을 위해 유지

영향:
- 각 메뉴에서 생성한 채번규칙은 해당 메뉴(및 형제 메뉴)에서만 표시
- global 규칙은 여전히 모든 메뉴에서 표시
- 기존 데이터는 영향 없음 (menu_objid NULL 조건 유지)
2025-11-20 18:05:49 +09:00
kjs 62463e1ca8 fix: 분할 패널 라벨 표시 설정 초기 렌더링 버그 수정
- displayMode가 undefined일 때 기본값 'list' 처리 누락
- 조건문을 (config.rightPanel?.displayMode || 'list') === 'list'로 변경
- 이제 처음 들어갔을 때부터 라벨 표시 설정 UI가 보임

문제: LIST 모드가 기본값인데 초기에는 설정 UI가 안 보이고 테이블 모드로 변경 후 다시 LIST로 바꿔야 보임
원인: undefined === 'list'가 false가 되어 조건문이 작동하지 않음
해결: 기본값 처리 추가
2025-11-20 18:00:30 +09:00
kjs 6e5e3a04f3 fix: 기존 필드의 자동 채우기 테이블 컬럼 초기 로드 추가
- 초기 렌더링 시 기존 필드들의 autoFillFromTable이 설정되어 있으면 컬럼 자동 로드
- useEffect로 localFields 초기화 시점에 모든 필드 순회하며 컬럼 로드
- 사용자가 저장된 설정을 열었을 때 즉시 컬럼 목록 표시

문제: 품목정보 테이블을 선택했지만 컬럼이 표시되지 않음
원인: 기존에 설정된 autoFillFromTable에 대한 컬럼이 초기 로드되지 않음
해결: 초기화 useEffect 추가로 기존 설정 복원
2025-11-20 17:52:40 +09:00
kjs 86eb9f0425 feat: 자동 채우기 테이블 선택 드롭다운 및 동적 컬럼 로드 추가
- 추가 입력 필드에서 자동 채우기 테이블을 드롭다운으로 선택 가능
- 텍스트 입력 대신 allTables에서 선택하는 방식으로 개선
- 테이블 선택 시 해당 테이블의 컬럼을 자동으로 로드
- autoFillTableColumns 상태로 필드별 테이블 컬럼 관리
- 선택한 테이블에 따라 컬럼 드롭다운이 동적으로 변경됨

사용자 경험 개선:
- 테이블명을 직접 입력하는 대신 목록에서 선택
- 선택한 테이블의 컬럼만 표시되어 혼란 방지
- 원본 테이블(기본) 또는 다른 테이블 선택 가능
2025-11-20 17:44:33 +09:00
kjs 6e92d1855a fix: SelectedItemsDetailInput 설정 패널에서 컬럼 자동 로드 추가
- 원본 테이블(sourceTable) 변경 시 컬럼 자동 로드
- 대상 테이블(targetTable) 변경 시 컬럼 자동 로드
- props로 받은 컬럼은 백업으로 사용하고, 내부에서 로드한 컬럼 우선 사용
- tableManagementApi.getColumnList() 사용하여 동적 로드

이제 원본/대상 테이블 선택 시 해당 테이블의 컬럼 목록이 자동으로 나타남
2025-11-20 17:37:51 +09:00
kjs c51cd7bc87 fix: 컴포넌트 설정 패널 config 병합 및 props 전달 개선
- TableListConfigPanel: handleNestedChange에서 전체 config 병합 로직 추가
- TableListComponent: checkbox.enabled 및 position 기본값 처리 (undefined시 기본값 사용)
- SelectedItemsDetailInputConfigPanel: handleChange에서 전체 config 병합 로직 추가
- SelectedItemsDetailInputConfigPanel: 원본 데이터 테이블 선택 disabled 조건 제거
- UnifiedPropertiesPanel: allTables 로드 및 ConfigPanel에 전달 추가

문제:
1. table-list 컴포넌트 체크박스 설정 변경 시 다른 설정 초기화
2. selected-items-detail-input 설정 변경 시 컴포넌트 이름 등 다른 속성 손실
3. 원본 데이터 테이블 선택이 비활성화되어 있음

해결:
- 모든 설정 패널에서 부분 업데이트 시 기존 config와 병합하도록 수정
- checkbox 관련 기본값 처리로 기존 컴포넌트 호환성 보장
- allTables를 별도로 로드하여 전체 테이블 목록 제공
2025-11-20 17:31:42 +09:00
kjs 6f3bcd7b46 fix: table-list 컴포넌트 컬럼 추가 시 체크박스 등 설정 유지
- UnifiedPropertiesPanel의 handleConfigChange에서 config 병합 로직 추가
- 기존 config와 새 config를 merge하여 checkbox 등 다른 설정이 사라지지 않도록 수정
- 이전에는 부분 업데이트된 config만 전달되어 다른 속성들이 손실되는 문제 해결
2025-11-20 17:18:30 +09:00
kjs d7db8cb07a fix: TableListConfigPanel에 screenTableName 전달 누락 수정
- renderComponentConfigPanel에서 ConfigPanelComponent 호출 시 screenTableName과 tableColumns 전달 추가
- 이전 커밋(e2cc09b2)에서 renderComponentConfigPanel 로직 추가로 인한 회귀 버그 수정
- table-list 컴포넌트 설정 패널에서 컬럼 추가 기능 정상 작동
2025-11-20 17:07:12 +09:00
dohyeons eb6fe57839 dashboard 초기 목록 로딩방식을 csr로 변경 2025-11-20 16:37:52 +09:00
dohyeons 818fd5ac0d same key오류 해결 2025-11-20 16:25:26 +09:00
dohyeons 2facf19429 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map 2025-11-20 16:25:17 +09:00
kjs e2cc09b2d6 feat: 검색 필터 위젯 화면별 독립 설정 및 고정 모드 추가
- 검색 필터 설정을 화면별로 독립적으로 저장하도록 개선 (screenId 포함)
- FilterPanel, TableSearchWidget, TableListComponent에 화면 ID 기반 localStorage 키 적용
- 동적 모드(사용자 설정)와 고정 모드(디자이너 설정) 2가지 필터 방식 추가
- 고정 모드에서 컬럼 드롭다운 선택 기능 구현
- 컬럼 선택 시 라벨 및 필터 타입 자동 설정
- ConfigPanel 표시 문제 해결 (type='component' 지원)
- UnifiedPropertiesPanel에서 독립 컴포넌트 ConfigPanel 조회 개선

주요 변경:
- 같은 테이블을 사용하는 다른 화면에서 필터 설정이 독립적으로 관리됨
- 고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시
- 테이블 정보가 있으면 컬럼을 드롭다운으로 선택 가능
- inputType에 따라 filterType 자동 추론 (number, date, select, text)
2025-11-20 16:21:18 +09:00
kjs 3aee36515a Merge pull request 'feature/screen-management' (#215) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/215
2025-11-20 15:30:20 +09:00
dohyeons cdd9bdfd95 차량이 무조건 오른쪽을 보게 수정 2025-11-20 14:47:04 +09:00
dohyeons 751a5da119 Db와 rest api 같이 구현 2025-11-20 14:34:17 +09:00
35 changed files with 3426 additions and 1000 deletions

27
PLAN.MD Normal file
View File

@ -0,0 +1,27 @@
# 프로젝트: Digital Twin 에디터 안정화
## 개요
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
## 핵심 기능
1. `DigitalTwinEditor` 버그 수정
2. 비동기 함수 입력값 유효성 검증 강화
3. 외부 DB 연결 상태에 따른 방어 코드 추가
## 테스트 계획
### 1단계: 긴급 버그 수정
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
### 2단계: 잠재적 문제 점검
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
## 진행 상태
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중

View File

@ -0,0 +1,57 @@
# 프로젝트 진행 상황 (2025-11-20)
## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조)
### 1. 핵심 변경 사항
기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다.
### 2. 완료된 작업
#### 데이터베이스
- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql`
- **스키마 변경**:
- `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가
- `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가
- 기존 하드코딩된 테이블 매핑 컬럼 제거
#### 백엔드 (Node.js)
- **API 추가/수정**:
- `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회
- `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회
- 기존 레거시 API (`getWarehouses` 등) 호환성 유지
- **컨트롤러 수정**:
- `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현
- `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리
#### 프론트엔드 (React)
- **신규 컴포넌트**: `HierarchyConfigPanel.tsx`
- 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI
- **유틸리티**: `spatialContainment.ts`
- `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB)
- `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동)
- **에디터 통합 (`DigitalTwinEditor.tsx`)**:
- `HierarchyConfigPanel` 적용
- 동적 데이터 로드 로직 구현
- 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용
- 객체 이동 시 그룹 이동 적용
### 3. 현재 상태
- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨)
- **DB**: 마이그레이션 스크립트 실행 완료
### 4. 다음 단계 (테스트 필요)
새로운 세션에서 다음 시나리오를 테스트해야 합니다:
1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장
2. **배치 검증**:
- 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함)
- 위치를 구역 **외부**에 배치 (실패해야 함)
3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인
### 5. 관련 파일
- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx`
- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx`
- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts`
- `backend-node/src/controllers/digitalTwinDataController.ts`
- `backend-node/src/routes/digitalTwinRoutes.ts`
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`

View File

@ -36,7 +36,138 @@ export async function getExternalDbConnector(connectionId: number) {
); );
} }
// 창고 목록 조회 (사용자 지정 테이블) // 동적 계층 구조 데이터 조회 (범용)
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig } = req.body;
if (!externalDbConnectionId || !hierarchyConfig) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const config = JSON.parse(hierarchyConfig);
const result: any = {
warehouse: null,
levels: [],
materials: [],
};
// 창고 데이터 조회
if (config.warehouse) {
const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`;
const warehouseResult = await connector.executeQuery(warehouseQuery);
result.warehouse = warehouseResult.rows;
}
// 각 레벨 데이터 조회
if (config.levels && Array.isArray(config.levels)) {
for (const level of config.levels) {
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
const levelResult = await connector.executeQuery(levelQuery);
result.levels.push({
level: level.level,
name: level.name,
data: levelResult.rows,
});
}
}
// 자재 데이터 조회 (개수만)
if (config.material) {
const materialQuery = `
SELECT
${config.material.locationKeyColumn} as location_key,
COUNT(*) as count
FROM ${config.material.tableName}
GROUP BY ${config.material.locationKeyColumn}
`;
const materialResult = await connector.executeQuery(materialQuery);
result.materials = materialResult.rows;
}
logger.info("동적 계층 구조 데이터 조회", {
externalDbConnectionId,
warehouseCount: result.warehouse?.length || 0,
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
});
return res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("동적 계층 구조 데이터 조회 실패", error);
return res.status(500).json({
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 특정 레벨의 하위 데이터 조회
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const config = JSON.parse(hierarchyConfig);
// 다음 레벨 찾기
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
if (!nextLevel) {
return res.json({
success: true,
data: [],
message: "하위 레벨이 없습니다.",
});
}
// 하위 데이터 조회
const query = `
SELECT * FROM ${nextLevel.tableName}
WHERE ${nextLevel.parentKeyColumn} = '${parentKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("하위 데이터 조회", {
externalDbConnectionId,
parentLevel,
parentKey,
count: result.rows.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("하위 데이터 조회 실패", error);
return res.status(500).json({
success: false,
message: "하위 데이터 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => { export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { externalDbConnectionId, tableName } = req.query; const { externalDbConnectionId, tableName } = req.query;
@ -83,32 +214,29 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
} }
}; };
// Area 목록 조회 (사용자 지정 테이블) // 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getAreas = async (req: Request, res: Response): Promise<Response> => { export const getAreas = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { externalDbConnectionId, tableName, warehouseKey } = req.query; const { externalDbConnectionId, warehouseKey, tableName } = req.query;
if (!externalDbConnectionId || !tableName) { if (!externalDbConnectionId || !warehouseKey || !tableName) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "외부 DB 연결 ID와 테이블명이 필요합니다.", message: "필수 파라미터가 누락되었습니다.",
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// 테이블명을 사용하여 모든 컬럼 조회 const query = `
let query = `SELECT * FROM ${tableName}`; SELECT * FROM ${tableName}
WHERE WAREKEY = '${warehouseKey}'
if (warehouseKey) { LIMIT 1000
query += ` WHERE WAREKEY = '${warehouseKey}'`; `;
}
query += ` LIMIT 1000`;
const result = await connector.executeQuery(query); const result = await connector.executeQuery(query);
logger.info("Area 목록 조회", { logger.info("구역 목록 조회", {
externalDbConnectionId, externalDbConnectionId,
tableName, tableName,
warehouseKey, warehouseKey,
@ -120,41 +248,38 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
data: result.rows, data: result.rows,
}); });
} catch (error: any) { } catch (error: any) {
logger.error("Area 목록 조회 실패", error); logger.error("구역 목록 조회 실패", error);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "Area 목록 조회 중 오류가 발생했습니다.", message: "구역 목록 조회 중 오류가 발생했습니다.",
error: error.message, error: error.message,
}); });
} }
}; };
// Location 목록 조회 (사용자 지정 테이블) // 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getLocations = async (req: Request, res: Response): Promise<Response> => { export const getLocations = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { externalDbConnectionId, tableName, areaKey } = req.query; const { externalDbConnectionId, areaKey, tableName } = req.query;
if (!externalDbConnectionId || !tableName) { if (!externalDbConnectionId || !areaKey || !tableName) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "외부 DB 연결 ID와 테이블명이 필요합니다.", message: "필수 파라미터가 누락되었습니다.",
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// 테이블명을 사용하여 모든 컬럼 조회 const query = `
let query = `SELECT * FROM ${tableName}`; SELECT * FROM ${tableName}
WHERE AREAKEY = '${areaKey}'
if (areaKey) { LIMIT 1000
query += ` WHERE AREAKEY = '${areaKey}'`; `;
}
query += ` LIMIT 1000`;
const result = await connector.executeQuery(query); const result = await connector.executeQuery(query);
logger.info("Location 목록 조회", { logger.info("위치 목록 조회", {
externalDbConnectionId, externalDbConnectionId,
tableName, tableName,
areaKey, areaKey,
@ -166,37 +291,46 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
data: result.rows, data: result.rows,
}); });
} catch (error: any) { } catch (error: any) {
logger.error("Location 목록 조회 실패", error); logger.error("위치 목록 조회 실패", error);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "Location 목록 조회 중 오류가 발생했습니다.", message: "위치 목록 조회 중 오류가 발생했습니다.",
error: error.message, error: error.message,
}); });
} }
}; };
// 자재 목록 조회 (사용자 지정 테이블) // 자재 목록 조회 (동적 컬럼 매핑 지원)
export const getMaterials = async (req: Request, res: Response): Promise<Response> => { export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { externalDbConnectionId, tableName, locaKey } = req.query; const {
externalDbConnectionId,
locaKey,
tableName,
keyColumn,
locationKeyColumn,
layerColumn
} = req.query;
if (!externalDbConnectionId || !tableName) { if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "외부 DB 연결 ID와 테이블명이 필요합니다.", message: "필수 파라미터가 누락되었습니다.",
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// 테이블명을 사용하여 모든 컬럼 조회 // 동적 쿼리 생성
let query = `SELECT * FROM ${tableName}`; const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
const query = `
SELECT * FROM ${tableName}
WHERE ${locationKeyColumn} = '${locaKey}'
${orderByClause}
LIMIT 1000
`;
if (locaKey) { logger.info(`자재 조회 쿼리: ${query}`);
query += ` WHERE LOCAKEY = '${locaKey}'`;
}
query += ` LIMIT 1000`;
const result = await connector.executeQuery(query); const result = await connector.executeQuery(query);
@ -221,31 +355,28 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
} }
}; };
// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블) // 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => { export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { externalDbConnectionId, tableName, locaKeys } = req.query; const { externalDbConnectionId, locationKeys, tableName } = req.body;
if (!externalDbConnectionId || !tableName || !locaKeys) { if (!externalDbConnectionId || !locationKeys || !tableName) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.", message: "필수 파라미터가 누락되었습니다.",
}); });
} }
const connector = await getExternalDbConnector(Number(externalDbConnectionId)); const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// locaKeys는 쉼표로 구분된 문자열 const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
const locaKeyArray = (locaKeys as string).split(",");
const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(",");
const query = ` const query = `
SELECT SELECT
LOCAKEY, LOCAKEY as location_key,
COUNT(*) as material_count, COUNT(*) as count
MAX(LOLAYER) as max_layer
FROM ${tableName} FROM ${tableName}
WHERE LOCAKEY IN (${quotedKeys}) WHERE LOCAKEY IN (${keysString})
GROUP BY LOCAKEY GROUP BY LOCAKEY
`; `;
@ -254,7 +385,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
logger.info("자재 개수 조회", { logger.info("자재 개수 조회", {
externalDbConnectionId, externalDbConnectionId,
tableName, tableName,
locaKeyCount: locaKeyArray.length, locationCount: locationKeys.length,
}); });
return res.json({ return res.json({
@ -270,4 +401,3 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
}); });
} }
}; };

View File

@ -1,10 +1,11 @@
import { Request, Response } from "express"; import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db"; import { pool } from "../database/db";
import logger from "../utils/logger"; import logger from "../utils/logger";
// 레이아웃 목록 조회 // 레이아웃 목록 조회
export const getLayouts = async ( export const getLayouts = async (
req: Request, req: AuthenticatedRequest,
res: Response res: Response
): Promise<Response> => { ): Promise<Response> => {
try { try {
@ -67,7 +68,7 @@ export const getLayouts = async (
// 레이아웃 상세 조회 (객체 포함) // 레이아웃 상세 조회 (객체 포함)
export const getLayoutById = async ( export const getLayoutById = async (
req: Request, req: AuthenticatedRequest,
res: Response res: Response
): Promise<Response> => { ): Promise<Response> => {
try { try {
@ -125,7 +126,7 @@ export const getLayoutById = async (
// 레이아웃 생성 // 레이아웃 생성
export const createLayout = async ( export const createLayout = async (
req: Request, req: AuthenticatedRequest,
res: Response res: Response
): Promise<Response> => { ): Promise<Response> => {
const client = await pool.connect(); const client = await pool.connect();
@ -138,6 +139,7 @@ export const createLayout = async (
warehouseKey, warehouseKey,
layoutName, layoutName,
description, description,
hierarchyConfig,
objects, objects,
} = req.body; } = req.body;
@ -147,9 +149,9 @@ export const createLayout = async (
const layoutQuery = ` const layoutQuery = `
INSERT INTO digital_twin_layout ( INSERT INTO digital_twin_layout (
company_code, external_db_connection_id, warehouse_key, company_code, external_db_connection_id, warehouse_key,
layout_name, description, created_by, updated_by layout_name, description, hierarchy_config, created_by, updated_by
) )
VALUES ($1, $2, $3, $4, $5, $6, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING * RETURNING *
`; `;
@ -159,6 +161,7 @@ export const createLayout = async (
warehouseKey, warehouseKey,
layoutName, layoutName,
description, description,
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
userId, userId,
]); ]);
@ -174,9 +177,10 @@ export const createLayout = async (
rotation, color, rotation, color,
area_key, loca_key, loc_type, area_key, loca_key, loc_type,
material_count, material_preview_height, material_count, material_preview_height,
parent_id, display_order, locked parent_id, display_order, locked,
hierarchy_level, parent_key, external_key
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
`; `;
for (const obj of objects) { for (const obj of objects) {
@ -200,6 +204,9 @@ export const createLayout = async (
obj.parentId || null, obj.parentId || null,
obj.displayOrder || 0, obj.displayOrder || 0,
obj.locked || false, obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]); ]);
} }
} }
@ -231,7 +238,7 @@ export const createLayout = async (
// 레이아웃 수정 // 레이아웃 수정
export const updateLayout = async ( export const updateLayout = async (
req: Request, req: AuthenticatedRequest,
res: Response res: Response
): Promise<Response> => { ): Promise<Response> => {
const client = await pool.connect(); const client = await pool.connect();
@ -240,7 +247,14 @@ export const updateLayout = async (
const companyCode = req.user?.companyCode; const companyCode = req.user?.companyCode;
const userId = req.user?.userId; const userId = req.user?.userId;
const { id } = req.params; const { id } = req.params;
const { layoutName, description, objects } = req.body; const {
layoutName,
description,
hierarchyConfig,
externalDbConnectionId,
warehouseKey,
objects,
} = req.body;
await client.query("BEGIN"); await client.query("BEGIN");
@ -249,15 +263,21 @@ export const updateLayout = async (
UPDATE digital_twin_layout UPDATE digital_twin_layout
SET layout_name = $1, SET layout_name = $1,
description = $2, description = $2,
updated_by = $3, hierarchy_config = $3,
external_db_connection_id = $4,
warehouse_key = $5,
updated_by = $6,
updated_at = NOW() updated_at = NOW()
WHERE id = $4 AND company_code = $5 WHERE id = $7 AND company_code = $8
RETURNING * RETURNING *
`; `;
const layoutResult = await client.query(updateLayoutQuery, [ const layoutResult = await client.query(updateLayoutQuery, [
layoutName, layoutName,
description, description,
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
externalDbConnectionId || null,
warehouseKey || null,
userId, userId,
id, id,
companyCode, companyCode,
@ -277,7 +297,7 @@ export const updateLayout = async (
[id] [id]
); );
// 새 객체 저장 // 새 객체 저장 (부모-자식 관계 처리)
if (objects && objects.length > 0) { if (objects && objects.length > 0) {
const objectQuery = ` const objectQuery = `
INSERT INTO digital_twin_objects ( INSERT INTO digital_twin_objects (
@ -287,12 +307,53 @@ export const updateLayout = async (
rotation, color, rotation, color,
area_key, loca_key, loc_type, area_key, loca_key, loc_type,
material_count, material_preview_height, material_count, material_preview_height,
parent_id, display_order, locked parent_id, display_order, locked,
hierarchy_level, parent_key, external_key
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING id
`; `;
for (const obj of objects) { // 임시 ID (음수) → 실제 DB ID 매핑
const idMapping: { [tempId: number]: number } = {};
// 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들)
for (const obj of objects.filter((o) => !o.parentId)) {
const result = await client.query(objectQuery, [
id,
obj.type,
obj.name,
obj.position.x,
obj.position.y,
obj.position.z,
obj.size.x,
obj.size.y,
obj.size.z,
obj.rotation || 0,
obj.color,
obj.areaKey || null,
obj.locaKey || null,
obj.locType || null,
obj.materialCount || 0,
obj.materialPreview?.height || null,
null, // parent_id
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
// 임시 ID와 실제 DB ID 매핑
if (obj.id) {
idMapping[obj.id] = result.rows[0].id;
}
}
// 2단계: 자식 객체 저장 (parentId가 있는 것들)
for (const obj of objects.filter((o) => o.parentId)) {
const realParentId = idMapping[obj.parentId!] || null;
await client.query(objectQuery, [ await client.query(objectQuery, [
id, id,
obj.type, obj.type,
@ -310,9 +371,12 @@ export const updateLayout = async (
obj.locType || null, obj.locType || null,
obj.materialCount || 0, obj.materialCount || 0,
obj.materialPreview?.height || null, obj.materialPreview?.height || null,
obj.parentId || null, realParentId, // 실제 DB ID 사용
obj.displayOrder || 0, obj.displayOrder || 0,
obj.locked || false, obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]); ]);
} }
} }
@ -344,7 +408,7 @@ export const updateLayout = async (
// 레이아웃 삭제 // 레이아웃 삭제
export const deleteLayout = async ( export const deleteLayout = async (
req: Request, req: AuthenticatedRequest,
res: Response res: Response
): Promise<Response> => { ): Promise<Response> => {
try { try {

View File

@ -1,4 +1,5 @@
import { Request, Response } from "express"; import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -6,7 +7,7 @@ import { logger } from "../utils/logger";
* API * API
* GET /api/entity-search/:tableName * GET /api/entity-search/:tableName
*/ */
export async function searchEntity(req: Request, res: Response) { export async function searchEntity(req: AuthenticatedRequest, res: Response) {
try { try {
const { tableName } = req.params; const { tableName } = req.params;
const { const {
@ -22,7 +23,8 @@ export async function searchEntity(req: Request, res: Response) {
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName }); logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.", message:
"테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
}); });
} }
@ -111,8 +113,10 @@ export async function searchEntity(req: Request, res: Response) {
}, },
}); });
} catch (error: any) { } catch (error: any) {
logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack }); logger.error("엔티티 검색 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({ success: false, message: error.message }); res.status(500).json({ success: false, message: error.message });
} }
} }

View File

@ -1,4 +1,5 @@
import { Request, Response } from "express"; import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -35,7 +36,7 @@ async function generateOrderNumber(companyCode: string): Promise<string> {
* API * API
* POST /api/orders * POST /api/orders
*/ */
export async function createOrder(req: Request, res: Response) { export async function createOrder(req: AuthenticatedRequest, res: Response) {
const pool = getPool(); const pool = getPool();
try { try {
@ -167,7 +168,7 @@ export async function createOrder(req: Request, res: Response) {
* API * API
* GET /api/orders * GET /api/orders
*/ */
export async function getOrders(req: Request, res: Response) { export async function getOrders(req: AuthenticatedRequest, res: Response) {
const pool = getPool(); const pool = getPool();
try { try {
@ -235,4 +236,3 @@ export async function getOrders(req: Request, res: Response) {
}); });
} }
} }

View File

@ -14,8 +14,17 @@ router.get(
authenticateToken, authenticateToken,
async (req: AuthenticatedRequest, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } = const {
req.query; leftTable,
rightTable,
leftColumn,
rightColumn,
leftValue,
dataFilter,
enableEntityJoin,
displayColumns,
deduplication,
} = req.query;
// 입력값 검증 // 입력값 검증
if (!leftTable || !rightTable || !leftColumn || !rightColumn) { if (!leftTable || !rightTable || !leftColumn || !rightColumn) {
@ -38,7 +47,9 @@ router.get(
} }
// 🆕 enableEntityJoin 파싱 // 🆕 enableEntityJoin 파싱
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; const enableEntityJoinFlag =
enableEntityJoin === "true" ||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
// SQL 인젝션 방지를 위한 검증 // SQL 인젝션 방지를 위한 검증
const tables = [leftTable as string, rightTable as string]; const tables = [leftTable as string, rightTable as string];
@ -68,7 +79,9 @@ router.get(
const userCompany = req.user?.companyCode; const userCompany = req.user?.companyCode;
// displayColumns 파싱 (item_info.item_name 등) // displayColumns 파싱 (item_info.item_name 등)
let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined; let parsedDisplayColumns:
| Array<{ name: string; label?: string }>
| undefined;
if (displayColumns) { if (displayColumns) {
try { try {
parsedDisplayColumns = JSON.parse(displayColumns as string); parsedDisplayColumns = JSON.parse(displayColumns as string);
@ -78,12 +91,14 @@ router.get(
} }
// 🆕 deduplication 파싱 // 🆕 deduplication 파싱
let parsedDeduplication: { let parsedDeduplication:
| {
enabled: boolean; enabled: boolean;
groupByColumn: string; groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string; sortColumn?: string;
} | undefined; }
| undefined;
if (deduplication) { if (deduplication) {
try { try {
parsedDeduplication = JSON.parse(deduplication as string); parsedDeduplication = JSON.parse(deduplication as string);
@ -340,7 +355,9 @@ router.get(
} }
const { enableEntityJoin, groupByColumns } = req.query; const { enableEntityJoin, groupByColumns } = req.query;
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; const enableEntityJoinFlag =
enableEntityJoin === "true" ||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
// groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분) // groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분)
let groupByColumnsArray: string[] = []; let groupByColumnsArray: string[] = [];
@ -350,7 +367,7 @@ router.get(
// JSON 형식이면 파싱, 아니면 쉼표로 분리 // JSON 형식이면 파싱, 아니면 쉼표로 분리
groupByColumnsArray = groupByColumns.startsWith("[") groupByColumnsArray = groupByColumns.startsWith("[")
? JSON.parse(groupByColumns) ? JSON.parse(groupByColumns)
: groupByColumns.split(",").map(c => c.trim()); : groupByColumns.split(",").map((c) => c.trim());
} }
} catch (error) { } catch (error) {
console.warn("groupByColumns 파싱 실패:", error); console.warn("groupByColumns 파싱 실패:", error);
@ -359,11 +376,16 @@ router.get(
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
enableEntityJoin: enableEntityJoinFlag, enableEntityJoin: enableEntityJoinFlag,
groupByColumns: groupByColumnsArray groupByColumns: groupByColumnsArray,
}); });
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray); const result = await dataService.getRecordDetail(
tableName,
id,
enableEntityJoinFlag,
groupByColumnsArray
);
if (!result.success) { if (!result.success) {
return res.status(400).json(result); return res.status(400).json(result);
@ -415,7 +437,8 @@ router.post(
if (!tableName || !parentKeys || !records || !Array.isArray(records)) { if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", message:
"필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
error: "MISSING_PARAMETERS", error: "MISSING_PARAMETERS",
}); });
} }
@ -450,17 +473,17 @@ router.post(
} }
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
inserted: result.inserted, inserted: result.data?.inserted || 0,
updated: result.updated, updated: result.data?.updated || 0,
deleted: result.deleted, deleted: result.data?.deleted || 0,
}); });
return res.json({ return res.json({
success: true, success: true,
message: "데이터가 저장되었습니다.", message: "데이터가 저장되었습니다.",
inserted: result.inserted, inserted: result.data?.inserted || 0,
updated: result.updated, updated: result.data?.updated || 0,
deleted: result.deleted, deleted: result.data?.deleted || 0,
}); });
} catch (error) { } catch (error) {
console.error("그룹화된 데이터 UPSERT 오류:", error); console.error("그룹화된 데이터 UPSERT 오류:", error);
@ -508,14 +531,20 @@ router.post(
const enrichedData = { ...data }; const enrichedData = { ...data };
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가 // 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code"); const hasCompanyCode = await dataService.checkColumnExists(
tableName,
"company_code"
);
if (hasCompanyCode && req.user?.companyCode) { if (hasCompanyCode && req.user?.companyCode) {
enrichedData.company_code = req.user.companyCode; enrichedData.company_code = req.user.companyCode;
console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`); console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`);
} }
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가 // 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name"); const hasCompanyName = await dataService.checkColumnExists(
tableName,
"company_name"
);
if (hasCompanyName && req.user?.companyName) { if (hasCompanyName && req.user?.companyName) {
enrichedData.company_name = req.user.companyName; enrichedData.company_name = req.user.companyName;
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
@ -679,7 +708,10 @@ router.post(
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
const result = await dataService.deleteGroupRecords(tableName, filterConditions); const result = await dataService.deleteGroupRecords(
tableName,
filterConditions
);
if (!result.success) { if (!result.success) {
return res.status(400).json(result); return res.status(400).json(result);

View File

@ -12,6 +12,8 @@ import {
// 외부 DB 데이터 조회 // 외부 DB 데이터 조회
import { import {
getHierarchyData,
getChildrenData,
getWarehouses, getWarehouses,
getAreas, getAreas,
getLocations, getLocations,
@ -32,6 +34,12 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
// ========== 외부 DB 데이터 조회 API ========== // ========== 외부 DB 데이터 조회 API ==========
// 동적 계층 구조 API
router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회
router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회
// 테이블 메타데이터 API
router.get("/data/tables/:connectionId", async (req, res) => { router.get("/data/tables/:connectionId", async (req, res) => {
// 테이블 목록 조회 // 테이블 목록 조회
try { try {
@ -56,11 +64,12 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => {
} }
}); });
// 레거시 API (호환성 유지)
router.get("/data/warehouses", getWarehouses); // 창고 목록 router.get("/data/warehouses", getWarehouses); // 창고 목록
router.get("/data/areas", getAreas); // Area 목록 router.get("/data/areas", getAreas); // Area 목록
router.get("/data/locations", getLocations); // Location 목록 router.get("/data/locations", getLocations); // Location 목록
router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location) router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location)
router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경
export default router; export default router;

View File

@ -300,10 +300,9 @@ class NumberingRuleService {
FROM numbering_rules FROM numbering_rules
WHERE WHERE
scope_type = 'global' scope_type = 'global'
OR scope_type = 'table'
OR (scope_type = 'menu' AND menu_objid = ANY($1)) OR (scope_type = 'menu' AND menu_objid = ANY($1))
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- 임시: table menu_objid OR (scope_type = 'table' AND menu_objid = ANY($1)) --
OR (scope_type = 'table' AND menu_objid IS NULL) -- 임시: (menu_objid NULL) OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( )
ORDER BY ORDER BY
CASE CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
@ -313,9 +312,9 @@ class NumberingRuleService {
created_at DESC created_at DESC
`; `;
params = [siblingObjids]; params = [siblingObjids];
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids }); logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
} else { } else {
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함) // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -336,10 +335,9 @@ class NumberingRuleService {
WHERE company_code = $1 WHERE company_code = $1
AND ( AND (
scope_type = 'global' scope_type = 'global'
OR scope_type = 'table'
OR (scope_type = 'menu' AND menu_objid = ANY($2)) OR (scope_type = 'menu' AND menu_objid = ANY($2))
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- 임시: table menu_objid OR (scope_type = 'table' AND menu_objid = ANY($2)) --
OR (scope_type = 'table' AND menu_objid IS NULL) -- 임시: (menu_objid NULL) OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( )
) )
ORDER BY ORDER BY
CASE CASE
@ -350,7 +348,7 @@ class NumberingRuleService {
created_at DESC created_at DESC
`; `;
params = [companyCode, siblingObjids]; params = [companyCode, siblingObjids];
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids }); logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
} }
logger.info("🔍 채번 규칙 쿼리 실행", { logger.info("🔍 채번 규칙 쿼리 실행", {

View File

@ -18,32 +18,26 @@ import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
interface DashboardListClientProps {
initialDashboards: Dashboard[];
initialPagination: {
total: number;
page: number;
limit: number;
};
}
/** /**
* *
* - CSR
* - * -
* - /// * - ///
*/ */
export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) { export default function DashboardListClient() {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [dashboards, setDashboards] = useState<Dashboard[]>(initialDashboards);
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료 // 상태 관리
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
// 페이지네이션 상태 // 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(initialPagination.page); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPagination.limit); const [pageSize, setPageSize] = useState(10);
const [totalCount, setTotalCount] = useState(initialPagination.total); const [totalCount, setTotalCount] = useState(0);
// 모달 상태 // 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -73,17 +67,8 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
} }
}; };
// 초기 로드 여부 추적 // 검색어/페이지 변경 시 fetch (초기 로딩 포함)
const [isInitialLoad, setIsInitialLoad] = useState(true);
useEffect(() => { useEffect(() => {
// 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음)
if (isInitialLoad) {
setIsInitialLoad(false);
return;
}
// 이후 검색어/페이지 변경 시에만 fetch
loadDashboards(); loadDashboards();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, currentPage, pageSize]); }, [searchTerm, currentPage, pageSize]);
@ -91,7 +76,7 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
// 페이지네이션 정보 계산 // 페이지네이션 정보 계산
const paginationInfo: PaginationInfo = { const paginationInfo: PaginationInfo = {
currentPage, currentPage,
totalPages: Math.ceil(totalCount / pageSize), totalPages: Math.ceil(totalCount / pageSize) || 1,
totalItems: totalCount, totalItems: totalCount,
itemsPerPage: pageSize, itemsPerPage: pageSize,
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,

View File

@ -1,73 +1,22 @@
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
import { cookies } from "next/headers";
/** /**
* fetch *
* -
* - CSR로
*/ */
async function getInitialDashboards() { export default function DashboardListPage() {
try {
// 서버 사이드 전용: 백엔드 API 직접 호출
// 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1
const backendUrl = process.env.SERVER_API_URL || "http://backend:8080";
// 쿠키에서 authToken 추출
const cookieStore = await cookies();
const authToken = cookieStore.get("authToken")?.value;
if (!authToken) {
// 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드)
return {
dashboards: [],
pagination: { total: 0, page: 1, limit: 10 },
};
}
const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, {
cache: "no-store", // 항상 최신 데이터
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달
},
});
if (!response.ok) {
throw new Error(`Failed to fetch dashboards: ${response.status}`);
}
const data = await response.json();
return {
dashboards: data.data || [],
pagination: data.pagination || { total: 0, page: 1, limit: 10 },
};
} catch (error) {
console.error("Server-side fetch error:", error);
// 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능)
return {
dashboards: [],
pagination: { total: 0, page: 1, limit: 10 },
};
}
}
/**
* ( )
* - +
* -
*/
export default async function DashboardListPage() {
const initialData = await getInitialDashboards();
return ( return (
<div className="bg-background flex min-h-screen flex-col"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 (서버에서 렌더링) */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
{/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */} {/* 클라이언트 컴포넌트 */}
<DashboardListClient initialDashboards={initialData.dashboards} initialPagination={initialData.pagination} /> <DashboardListClient />
</div> </div>
</div> </div>
); );

View File

@ -1,138 +1,27 @@
"use client"; "use client";
import React, { useState } from "react"; import React from "react";
import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input";
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
export default function TestEntitySearchPage() { export default function TestEntitySearchPage() {
const [customerCode, setCustomerCode] = useState<string>("");
const [customerData, setCustomerData] = useState<any>(null);
const [itemCode, setItemCode] = useState<string>("");
const [itemData, setItemData] = useState<any>(null);
return ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto space-y-6 p-6">
<div> <div>
<h1 className="text-3xl font-bold">EntitySearchInput </h1> <h1 className="text-3xl font-bold">EntitySearchInput </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2"> .</p>
</div>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
메시지: ReferenceError: Cannot access &apos;h&apos; before initialization
</p> </p>
</div>
{/* 거래처 검색 테스트 - 자동완성 방식 */}
<Card>
<CardHeader>
<CardTitle> ( ) NEW</CardTitle>
<CardDescription>
-
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<AutocompleteSearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code", "business_number"]}
placeholder="거래처명 입력하여 검색"
showAdditionalInfo
additionalFields={["customer_code", "address", "contact_phone"]}
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerData(fullData);
}}
/>
</div>
{customerData && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h3 className="font-semibold mb-2"> :</h3>
<pre className="text-xs">
{JSON.stringify(customerData, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
{/* 거래처 검색 테스트 - 모달 방식 */}
<Card>
<CardHeader>
<CardTitle> ( )</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<EntitySearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code", "business_number"]}
mode="combo"
placeholder="거래처를 검색하세요"
modalTitle="거래처 검색 및 선택"
modalColumns={["customer_code", "customer_name", "address", "contact_phone"]}
showAdditionalInfo
additionalFields={["address", "contact_phone", "business_number"]}
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerData(fullData);
}}
/>
</div>
</CardContent>
</Card>
{/* 품목 검색 테스트 */}
<Card>
<CardHeader>
<CardTitle> (Modal )</CardTitle>
<CardDescription>
item_info
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<EntitySearchInputComponent
tableName="item_info"
displayField="item_name"
valueField="id"
searchFields={["item_name", "id", "item_number"]}
mode="modal"
placeholder="품목 선택"
modalTitle="품목 검색"
modalColumns={["id", "item_name", "item_number", "unit", "selling_price"]}
showAdditionalInfo
additionalFields={["item_number", "unit", "selling_price"]}
value={itemCode}
onChange={(code, fullData) => {
setItemCode(code || "");
setItemData(fullData);
}}
/>
</div>
{itemData && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h3 className="font-semibold mb-2"> :</h3>
<pre className="text-xs">
{JSON.stringify(itemData, null, 2)}
</pre>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

@ -1,87 +1,27 @@
"use client"; "use client";
import React, { useState } from "react"; import React from "react";
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function TestOrderRegistrationPage() { export default function TestOrderRegistrationPage() {
const [modalOpen, setModalOpen] = useState(false);
const handleSuccess = () => {
console.log("수주 등록 성공!");
};
return ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto space-y-6 p-6">
<div> <div>
<h1 className="text-3xl font-bold"> </h1> <h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2"> .</p>
EntitySearchInput + ModalRepeaterTable을
</p>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
<CardDescription> <CardDescription>ModalRepeaterTable .</CardDescription>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button onClick={() => setModalOpen(true)}> <p className="text-muted-foreground text-sm">
메시지: ReferenceError: Cannot access &apos;h&apos; before initialization
</Button> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span>EntitySearchInput: 거래처 ( )</span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span>ModalRepeaterTable: 품목 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 계산: 수량 × = </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 편집: 수량, , , </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 방지: 이미 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 삭제: 추가된 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 표시: 모든 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 전환: 거래처 / / </span>
</div>
</CardContent>
</Card>
{/* 수주 등록 모달 */}
<OrderRegistrationModal
open={modalOpen}
onOpenChange={setModalOpen}
onSuccess={handleSuccess}
/>
</div> </div>
); );
} }

View File

@ -199,14 +199,14 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
onValueChange={(value: "current" | "external") => onChange({ connectionType: value })} onValueChange={(value: "current" | "external") => onChange({ connectionType: value })}
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={"current-${dataSource.id}"} /> <RadioGroupItem value="current" id={`current-${dataSource.id}`} />
<Label htmlFor={"current-${dataSource.id}"} className="text-xs font-normal"> <Label htmlFor={`current-${dataSource.id}`} className="text-xs font-normal">
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="external" id={"external-${dataSource.id}"} /> <RadioGroupItem value="external" id={`external-${dataSource.id}`} />
<Label htmlFor={"external-${dataSource.id}"} className="text-xs font-normal"> <Label htmlFor={`external-${dataSource.id}`} className="text-xs font-normal">
</Label> </Label>
</div> </div>
@ -216,7 +216,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
{/* 외부 DB 선택 */} {/* 외부 DB 선택 */}
{dataSource.connectionType === "external" && ( {dataSource.connectionType === "external" && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={"external-conn-${dataSource.id}"} className="text-xs"> <Label htmlFor={`external-conn-${dataSource.id}`} className="text-xs">
* *
</Label> </Label>
{loadingConnections ? ( {loadingConnections ? (
@ -246,7 +246,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
{/* SQL 쿼리 */} {/* SQL 쿼리 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor={"query-${dataSource.id}"} className="text-xs"> <Label htmlFor={`query-${dataSource.id}`} className="text-xs">
SQL * SQL *
</Label> </Label>
<Select <Select
@ -313,7 +313,7 @@ ORDER BY 하위부서수 DESC`,
</Select> </Select>
</div> </div>
<Textarea <Textarea
id={"query-${dataSource.id}"} id={`query-${dataSource.id}`}
value={dataSource.query || ""} value={dataSource.query || ""}
onChange={(e) => onChange({ query: e.target.value })} onChange={(e) => onChange({ query: e.target.value })}
placeholder="SELECT * FROM table_name WHERE ..." placeholder="SELECT * FROM table_name WHERE ..."
@ -340,6 +340,9 @@ ORDER BY 하위부서수 DESC`,
<SelectItem value="arrow" className="text-xs"> <SelectItem value="arrow" className="text-xs">
</SelectItem> </SelectItem>
<SelectItem value="truck" className="text-xs">
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-[10px]"> </p> <p className="text-muted-foreground text-[10px]"> </p>

View File

@ -2,10 +2,11 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck } from "lucide-react"; import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin";
@ -17,9 +18,14 @@ import {
updateLayout, updateLayout,
getMaterialCounts, getMaterialCounts,
getMaterials, getMaterials,
getHierarchyData,
getChildrenData,
type HierarchyData,
} from "@/lib/api/digitalTwin"; } from "@/lib/api/digitalTwin";
import type { MaterialData } from "@/types/digitalTwin"; import type { MaterialData } from "@/types/digitalTwin";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment";
// 백엔드 DB 객체 타입 (snake_case) // 백엔드 DB 객체 타입 (snake_case)
interface DbObject { interface DbObject {
@ -86,24 +92,48 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const [loadingMaterials, setLoadingMaterials] = useState(false); const [loadingMaterials, setLoadingMaterials] = useState(false);
const [showMaterialPanel, setShowMaterialPanel] = useState(false); const [showMaterialPanel, setShowMaterialPanel] = useState(false);
// 테이블 매핑 관련 상태 // 동적 계층 구조 설정
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
const [availableTables, setAvailableTables] = useState<string[]>([]); const [availableTables, setAvailableTables] = useState<string[]>([]);
const [loadingTables, setLoadingTables] = useState(false); const [loadingTables, setLoadingTables] = useState(false);
const [selectedTables, setSelectedTables] = useState({
// 레거시: 테이블 매핑 (구 Area/Location 방식 호환용)
const [selectedTables, setSelectedTables] = useState<{
warehouse: string;
area: string;
location: string;
material: string;
}>({
warehouse: "", warehouse: "",
area: "", area: "",
location: "", location: "",
material: "", material: "",
}); });
const [tableColumns, setTableColumns] = useState<{ [key: string]: string[] }>({}); const [tableColumns, setTableColumns] = useState<{
const [selectedColumns, setSelectedColumns] = useState({ warehouse?: { name: string; code: string };
area?: { name: string; code: string; warehouseCode: string };
location?: { name: string; code: string; areaCode: string };
}>({});
const [selectedColumns, setSelectedColumns] = useState<{
warehouseKey: string;
warehouseName: string;
areaKey: string;
areaName: string;
areaWarehouseKey: string;
locationKey: string;
locationName: string;
locationAreaKey: string;
materialKey?: string;
}>({
warehouseKey: "WAREKEY", warehouseKey: "WAREKEY",
warehouseName: "WARENAME", warehouseName: "WARENAME",
areaKey: "AREAKEY", areaKey: "AREAKEY",
areaName: "AREANAME", areaName: "AREANAME",
areaWarehouseKey: "WAREKEY",
locationKey: "LOCAKEY", locationKey: "LOCAKEY",
locationName: "LOCANAME", locationName: "LOCANAME",
materialKey: "STKKEY", locationAreaKey: "AREAKEY",
materialKey: "LOCAKEY",
}); });
// placedObjects를 YardPlacement 형식으로 변환 (useMemo로 최적화) // placedObjects를 YardPlacement 형식으로 변환 (useMemo로 최적화)
@ -140,7 +170,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try { try {
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
console.log("🔍 외부 DB 연결 목록 (is_active=Y):", connections); console.log("🔍 외부 DB 연결 목록 (is_active=Y):", connections);
console.log("🔍 연결 ID들:", connections.map(c => c.id)); console.log(
"🔍 연결 ID들:",
connections.map((c) => c.id),
);
setExternalDbConnections( setExternalDbConnections(
connections.map((conn) => ({ connections.map((conn) => ({
id: conn.id!, id: conn.id!,
@ -166,7 +199,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
useEffect(() => { useEffect(() => {
if (!selectedDbConnection) { if (!selectedDbConnection) {
setAvailableTables([]); setAvailableTables([]);
setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); // warehouse는 HierarchyConfigPanel에서 관리
return; return;
} }
@ -196,6 +229,63 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDbConnection]); }, [selectedDbConnection]);
// 동적 계층 구조 데이터 로드
useEffect(() => {
const loadHierarchy = async () => {
if (!selectedDbConnection || !hierarchyConfig) {
return;
}
// 필수 필드 검증: 창고가 선택되었는지 확인
if (!hierarchyConfig.warehouseKey) {
return;
}
// 레벨 설정 검증
if (!hierarchyConfig.levels || hierarchyConfig.levels.length === 0) {
return;
}
// 각 레벨의 필수 필드 검증
for (const level of hierarchyConfig.levels) {
if (!level.tableName || !level.keyColumn || !level.nameColumn) {
return;
}
}
try {
const response = await getHierarchyData(selectedDbConnection, hierarchyConfig);
if (response.success && response.data) {
const { warehouse, levels, materials } = response.data;
// 창고 데이터 설정
if (warehouse) {
setWarehouses(warehouse);
}
// 레벨 데이터 설정
// 기존 호환성을 위해 레벨 1 -> Area, 레벨 2 -> Location으로 매핑
// TODO: UI를 동적으로 생성하도록 개선 필요
const level1 = levels.find((l) => l.level === 1);
if (level1) {
setAvailableAreas(level1.data);
}
const level2 = levels.find((l) => l.level === 2);
if (level2) {
setAvailableLocations(level2.data);
}
console.log("계층 데이터 로드 완료:", response.data);
}
} catch (error) {
console.error("계층 데이터 로드 실패:", error);
}
};
loadHierarchy();
}, [selectedDbConnection, hierarchyConfig]);
// 테이블 컬럼 로드 // 테이블 컬럼 로드
const loadColumnsForTable = async (tableName: string, type: "warehouse" | "area" | "location" | "material") => { const loadColumnsForTable = async (tableName: string, type: "warehouse" | "area" | "location" | "material") => {
if (!selectedDbConnection || !tableName) return; if (!selectedDbConnection || !tableName) return;
@ -208,13 +298,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
if (response.success && response.data && response.data.length > 0) { if (response.success && response.data && response.data.length > 0) {
const columns = Object.keys(response.data[0]); const columns = Object.keys(response.data[0]);
setTableColumns(prev => ({ ...prev, [type]: columns })); setTableColumns((prev) => ({ ...prev, [type]: columns }));
// 자동 매핑 시도 (기본값 설정) // 자동 매핑 시도 (기본값 설정)
if (type === "warehouse") { if (type === "warehouse") {
const keyCol = columns.find(c => c.includes("KEY") || c.includes("ID")) || columns[0]; const keyCol = columns.find((c) => c.includes("KEY") || c.includes("ID")) || columns[0];
const nameCol = columns.find(c => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0]; const nameCol = columns.find((c) => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0];
setSelectedColumns(prev => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol })); setSelectedColumns((prev) => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol }));
} }
} else { } else {
console.warn(`⚠️ ${tableName} 테이블에 데이터가 없습니다.`); console.warn(`⚠️ ${tableName} 테이블에 데이터가 없습니다.`);
@ -238,9 +328,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
} }
const loadWarehouses = async () => { const loadWarehouses = async () => {
if (!hierarchyConfig?.warehouse?.tableName) {
return;
}
try { try {
setLoadingWarehouses(true); setLoadingWarehouses(true);
const response = await getWarehouses(selectedDbConnection, selectedTables.warehouse); const response = await getWarehouses(selectedDbConnection, hierarchyConfig.warehouse.tableName);
console.log("📦 창고 API 응답:", response); console.log("📦 창고 API 응답:", response);
if (response.success && response.data) { if (response.success && response.data) {
console.log("📦 창고 데이터:", response.data); console.log("📦 창고 데이터:", response.data);
@ -279,7 +372,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
loadWarehouses(); loadWarehouses();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDbConnection, selectedTables.warehouse]); // toast 제거, warehouse 테이블 추가 }, [selectedDbConnection, hierarchyConfig?.warehouse?.tableName]); // hierarchyConfig.warehouse.tableName 추가
// 창고 선택 시 Area 목록 로드 // 창고 선택 시 Area 목록 로드
useEffect(() => { useEffect(() => {
@ -288,6 +381,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
return; return;
} }
// Area 테이블명이 설정되지 않으면 API 호출 스킵
if (!selectedTables.area) {
setAvailableAreas([]);
return;
}
const loadAreas = async () => { const loadAreas = async () => {
try { try {
setLoadingAreas(true); setLoadingAreas(true);
@ -324,6 +423,51 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const { layout, objects } = response.data; const { layout, objects } = response.data;
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
// 외부 DB 연결 ID 복원
if (layout.external_db_connection_id) {
setSelectedDbConnection(layout.external_db_connection_id);
}
// 계층 구조 설정 로드
if (layout.hierarchy_config) {
try {
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
// 선택된 테이블 정보도 복원
const newSelectedTables: any = {
warehouse: config.warehouse?.tableName || "",
area: "",
location: "",
material: "",
};
if (config.levels && config.levels.length > 0) {
// 레벨 1 = Area
if (config.levels[0]?.tableName) {
newSelectedTables.area = config.levels[0].tableName;
}
// 레벨 2 = Location
if (config.levels[1]?.tableName) {
newSelectedTables.location = config.levels[1].tableName;
}
}
// 자재 테이블 정보
if (config.material?.tableName) {
newSelectedTables.material = config.material.tableName;
}
setSelectedTables(newSelectedTables);
} catch (e) {
console.error("계층 구조 설정 파싱 실패:", e);
}
}
// 객체 데이터 변환 (DB -> PlacedObject) // 객체 데이터 변환 (DB -> PlacedObject)
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
id: obj.id, id: obj.id,
@ -352,6 +496,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
displayOrder: obj.display_order, displayOrder: obj.display_order,
locked: obj.locked, locked: obj.locked,
visible: obj.visible !== false, visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level || 1,
parentKey: obj.parent_key,
externalKey: obj.external_key,
})); }));
setPlacedObjects(loadedObjects); setPlacedObjects(loadedObjects);
@ -404,29 +551,37 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
useEffect(() => { useEffect(() => {
if (!layoutData || !layoutData.layout.externalDbConnectionId || externalDbConnections.length === 0) { console.log("🔍 useEffect 실행:", {
layoutData: !!layoutData,
external_db_connection_id: layoutData?.layout?.external_db_connection_id,
externalDbConnections: externalDbConnections.length,
});
if (!layoutData || !layoutData.layout.external_db_connection_id || externalDbConnections.length === 0) {
console.log("🔍 조건 미충족으로 종료");
return; return;
} }
const layout = layoutData.layout; const layout = layoutData.layout;
console.log("🔍 외부 DB 연결 자동 선택 시도"); console.log("🔍 외부 DB 연결 자동 선택 시도");
console.log("🔍 레이아웃의 externalDbConnectionId:", layout.externalDbConnectionId); console.log("🔍 레이아웃의 external_db_connection_id:", layout.external_db_connection_id);
console.log("🔍 사용 가능한 연결 목록:", externalDbConnections); console.log("🔍 사용 가능한 연결 목록:", externalDbConnections);
const connectionExists = externalDbConnections.some( const connectionExists = externalDbConnections.some((conn) => conn.id === layout.external_db_connection_id);
(conn) => conn.id === layout.externalDbConnectionId,
);
console.log("🔍 연결 존재 여부:", connectionExists); console.log("🔍 연결 존재 여부:", connectionExists);
if (connectionExists) { if (connectionExists) {
setSelectedDbConnection(layout.externalDbConnectionId); setSelectedDbConnection(layout.external_db_connection_id);
if (layout.warehouseKey) { if (layout.warehouse_key) {
setSelectedWarehouse(layout.warehouseKey); setSelectedWarehouse(layout.warehouse_key);
} }
console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.externalDbConnectionId); console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.external_db_connection_id);
} else { } else {
console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.externalDbConnectionId); console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.external_db_connection_id);
console.warn("⚠️ 사용 가능한 연결 ID들:", externalDbConnections.map(c => c.id)); console.warn(
"⚠️ 사용 가능한 연결 ID들:",
externalDbConnections.map((c) => c.id),
);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "외부 DB 연결 오류", title: "외부 DB 연결 오류",
@ -514,10 +669,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
let areaKey: string | undefined = undefined; let areaKey: string | undefined = undefined;
let locaKey: string | undefined = undefined; let locaKey: string | undefined = undefined;
let locType: string | undefined = undefined; let locType: string | undefined = undefined;
let hierarchyLevel = 1;
let parentKey: string | undefined = undefined;
let externalKey: string | undefined = undefined;
if (draggedTool === "area" && draggedAreaData) { if (draggedTool === "area" && draggedAreaData) {
objectName = draggedAreaData.AREANAME; objectName = draggedAreaData.AREANAME;
areaKey = draggedAreaData.AREAKEY; areaKey = draggedAreaData.AREAKEY;
// 계층 정보 설정 (예: Area는 레벨 1)
hierarchyLevel = 1;
externalKey = draggedAreaData.AREAKEY;
} else if ( } else if (
(draggedTool === "location-bed" || (draggedTool === "location-bed" ||
draggedTool === "location-stp" || draggedTool === "location-stp" ||
@ -529,6 +690,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey = draggedLocationData.AREAKEY; areaKey = draggedLocationData.AREAKEY;
locaKey = draggedLocationData.LOCAKEY; locaKey = draggedLocationData.LOCAKEY;
locType = draggedLocationData.LOCTYPE; locType = draggedLocationData.LOCTYPE;
// 계층 정보 설정 (예: Location은 레벨 2)
hierarchyLevel = 2;
parentKey = draggedLocationData.AREAKEY;
externalKey = draggedLocationData.LOCAKEY;
} }
const newObject: PlacedObject = { const newObject: PlacedObject = {
@ -541,8 +706,45 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey, areaKey,
locaKey, locaKey,
locType, locType,
hierarchyLevel,
parentKey,
externalKey,
}; };
// 공간적 종속성 검증
if (hierarchyConfig && hierarchyLevel > 1) {
const validation = validateSpatialContainment(
{
id: newObject.id,
position: newObject.position,
size: newObject.size,
hierarchyLevel: newObject.hierarchyLevel || 1,
parentId: newObject.parentId,
},
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
})),
);
if (!validation.valid) {
toast({
variant: "destructive",
title: "배치 오류",
description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.",
});
return;
}
// 부모 ID 설정
if (validation.parent) {
newObject.parentId = validation.parent.id;
}
}
setPlacedObjects((prev) => [...prev, newObject]); setPlacedObjects((prev) => [...prev, newObject]);
setSelectedObject(newObject); setSelectedObject(newObject);
setNextObjectId((prev) => prev - 1); setNextObjectId((prev) => prev - 1);
@ -561,22 +763,38 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
) { ) {
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
setTimeout(() => { setTimeout(() => {
loadMaterialCountsForLocations(); loadMaterialCountsForLocations([locaKey!]);
}, 100); }, 100);
} }
}; };
// Location의 자재 목록 로드 // Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string) => { const loadMaterialsForLocation = async (locaKey: string) => {
if (!selectedDbConnection) return; if (!selectedDbConnection || !hierarchyConfig?.material) {
toast({
variant: "destructive",
title: "자재 조회 실패",
description: "자재 테이블 설정이 필요합니다.",
});
return;
}
try { try {
setLoadingMaterials(true); setLoadingMaterials(true);
setShowMaterialPanel(true); setShowMaterialPanel(true);
const response = await getMaterials(selectedDbConnection, selectedTables.material, locaKey); const response = await getMaterials(selectedDbConnection, {
...hierarchyConfig.material,
locaKey: locaKey,
});
if (response.success && response.data) { if (response.success && response.data) {
// LOLAYER 순으로 정렬 // layerColumn이 있으면 정렬
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); const sortedMaterials = hierarchyConfig.material.layerColumn
? response.data.sort((a: any, b: any) => {
const aLayer = a[hierarchyConfig.material!.layerColumn!] || 0;
const bLayer = b[hierarchyConfig.material!.layerColumn!] || 0;
return aLayer - bLayer;
})
: response.data;
setMaterials(sortedMaterials); setMaterials(sortedMaterials);
} else { } else {
setMaterials([]); setMaterials([]);
@ -688,20 +906,60 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 객체 이동 // 객체 이동
const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => {
// Yard3DCanvas에서 이미 스냅+오프셋이 완료된 좌표를 받음 setPlacedObjects((prev) => {
// 그대로 저장하면 됨 const targetObj = prev.find((obj) => obj.id === objectId);
setPlacedObjects((prev) => if (!targetObj) return prev;
prev.map((obj) => {
const oldPosition = targetObj.position;
const newPosition = {
x: newX,
y: newY !== undefined ? newY : oldPosition.y,
z: newZ,
};
// 1. 이동 대상 객체 업데이트
let updatedObjects = prev.map((obj) => {
if (obj.id === objectId) { if (obj.id === objectId) {
const newPosition = { ...obj.position, x: newX, z: newZ };
if (newY !== undefined) {
newPosition.y = newY;
}
return { ...obj, position: newPosition }; return { ...obj, position: newPosition };
} }
return obj; return obj;
}), });
);
// 2. 그룹 이동: 자식 객체들도 함께 이동
const spatialObjects = updatedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}));
const descendants = getAllDescendants(objectId, spatialObjects);
if (descendants.length > 0) {
const delta = {
x: newPosition.x - oldPosition.x,
y: newPosition.y - oldPosition.y,
z: newPosition.z - oldPosition.z,
};
updatedObjects = updatedObjects.map((obj) => {
if (descendants.some((d) => d.id === obj.id)) {
return {
...obj,
position: {
x: obj.position.x + delta.x,
y: obj.position.y + delta.y,
z: obj.position.z + delta.z,
},
};
}
return obj;
});
}
return updatedObjects;
});
if (selectedObject?.id === objectId) { if (selectedObject?.id === objectId) {
setSelectedObject((prev) => { setSelectedObject((prev) => {
@ -803,6 +1061,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const response = await updateLayout(layoutId, { const response = await updateLayout(layoutId, {
layoutName: layoutName, layoutName: layoutName,
description: undefined, description: undefined,
hierarchyConfig: hierarchyConfig, // 계층 구조 설정
externalDbConnectionId: selectedDbConnection, // 외부 DB 연결 ID
warehouseKey: selectedWarehouse, // 선택된 창고
objects: placedObjects.map((obj, index) => ({ objects: placedObjects.map((obj, index) => ({
...obj, ...obj,
displayOrder: index, // 현재 순서대로 저장 displayOrder: index, // 현재 순서대로 저장
@ -935,7 +1196,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{/* 좌측: 외부 DB 선택 + 객체 목록 */} {/* 좌측: 외부 DB 선택 + 객체 목록 */}
<div className="flex h-full w-[20%] flex-col border-r"> <div className="flex h-full w-[20%] flex-col border-r">
{/* 스크롤 영역 */} {/* 스크롤 영역 */}
<div className="flex-1 overflow-y-auto p-4 space-y-6"> <div className="flex-1 space-y-6 overflow-y-auto p-4">
{/* 외부 DB 선택 */} {/* 외부 DB 선택 */}
<div> <div>
<Label className="mb-2 block text-sm font-semibold"> </Label> <Label className="mb-2 block text-sm font-semibold"> </Label>
@ -960,156 +1221,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Select> </Select>
</div> </div>
{/* 테이블 매핑 선택 */} {/* 창고 테이블 및 컬럼 매핑 */}
{selectedDbConnection && ( {selectedDbConnection && (
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>
{loadingTables ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : (
<>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> </Label>
<Select
value={selectedTables.warehouse}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, warehouse: value });
loadColumnsForTable(value, "warehouse");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 창고 컬럼 매핑 */} {/* 이 레이아웃의 창고 선택 */}
{selectedTables.warehouse && tableColumns.warehouse && ( {hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
<div className="ml-2 space-y-2 border-l-2 pl-2">
<div> <div>
<Label className="text-muted-foreground mb-1 block text-[10px]">ID </Label> <Label className="text-muted-foreground mb-1 block text-xs"> </Label>
<Select
value={selectedColumns.warehouseKey}
onValueChange={(value) => setSelectedColumns({ ...selectedColumns, warehouseKey: value })}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tableColumns.warehouse.map((col) => (
<SelectItem key={col} value={col} className="text-xs">{col}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground mb-1 block text-[10px]"> </Label>
<Select
value={selectedColumns.warehouseName}
onValueChange={(value) => setSelectedColumns({ ...selectedColumns, warehouseName: value })}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tableColumns.warehouse.map((col) => (
<SelectItem key={col} value={col} className="text-xs">{col}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> (: Area)</Label>
<Select
value={selectedTables.area}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, area: value });
loadColumnsForTable(value, "area");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> (: Location)</Label>
<Select
value={selectedTables.location}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, location: value });
loadColumnsForTable(value, "location");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> </Label>
<Select
value={selectedTables.material}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, material: value });
loadColumnsForTable(value, "material");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 창고 선택 */}
{selectedDbConnection && selectedTables.warehouse && (
<div>
<Label className="mb-2 block text-sm font-semibold"></Label>
{loadingWarehouses ? ( {loadingWarehouses ? (
<div className="flex h-10 items-center justify-center"> <div className="flex h-9 items-center justify-center rounded-md border">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" /> <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div> </div>
) : ( ) : (
@ -1117,27 +1239,93 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
value={selectedWarehouse || ""} value={selectedWarehouse || ""}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedWarehouse(value); setSelectedWarehouse(value);
// hierarchyConfig 업데이트 (없으면 새로 생성)
setHierarchyConfig((prev) => ({
warehouseKey: value,
levels: prev?.levels || [],
material: prev?.material,
}));
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
}} }}
> >
<SelectTrigger className="h-10 text-sm"> <SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="창고 선택..." /> <SelectValue placeholder="창고 선택..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{warehouses.map((wh: any) => ( {warehouses.map((wh: any) => {
<SelectItem const keyCol = hierarchyConfig.warehouse!.keyColumn;
key={wh[selectedColumns.warehouseKey] || wh.WAREKEY} const nameCol = hierarchyConfig.warehouse!.nameColumn;
value={wh[selectedColumns.warehouseKey] || wh.WAREKEY} return (
className="text-sm" <SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
> {wh[nameCol] || wh[keyCol]}
{wh[selectedColumns.warehouseName] || wh.WARENAME || wh[selectedColumns.warehouseKey] || wh.WAREKEY}
</SelectItem> </SelectItem>
))} );
})}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
</div> </div>
)} )}
</div>
)}
{/* 계층 설정 패널 (신규) */}
{selectedDbConnection && (
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={(config) => {
// 새로운 객체로 생성하여 참조 변경 (useEffect 트리거를 위해)
setHierarchyConfig({ ...config });
// 레벨 테이블 정보를 selectedTables와 동기화
const newSelectedTables: any = { ...selectedTables };
// 창고 테이블 정보
if (config.warehouse?.tableName) {
newSelectedTables.warehouse = config.warehouse.tableName;
}
if (config.levels && config.levels.length > 0) {
// 레벨 1 = Area
if (config.levels[0]?.tableName) {
newSelectedTables.area = config.levels[0].tableName;
}
// 레벨 2 = Location
if (config.levels[1]?.tableName) {
newSelectedTables.location = config.levels[1].tableName;
}
}
// 자재 테이블 정보
if (config.material?.tableName) {
newSelectedTables.material = config.material.tableName;
}
setSelectedTables(newSelectedTables);
setHasUnsavedChanges(true);
}}
availableTables={availableTables}
onLoadTables={async () => {
// 이미 로드되어 있으므로 스킵
}}
onLoadColumns={async (tableName: string) => {
try {
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
if (response.success && response.data) {
// 객체 배열을 문자열 배열로 변환
return response.data.map((col: any) =>
typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
);
}
return [];
} catch (error) {
console.error("컬럼 로드 실패:", error);
return [];
}
}}
/>
)}
{/* Area 목록 */} {/* Area 목록 */}
{selectedDbConnection && selectedWarehouse && ( {selectedDbConnection && selectedWarehouse && (
@ -1151,11 +1339,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<p className="text-muted-foreground text-xs">Area가 </p> <p className="text-muted-foreground text-xs">Area가 </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{availableAreas.map((area) => ( {availableAreas.map((area) => {
// 이미 배치된 Area인지 확인
const isPlaced = placedObjects.some((obj) => obj.areaKey === area.AREAKEY);
return (
<div <div
key={area.AREAKEY} key={area.AREAKEY}
draggable draggable={!isPlaced}
onDragStart={() => { onDragStart={() => {
if (isPlaced) return;
// Area 정보를 임시 저장 // Area 정보를 임시 저장
setDraggedTool("area"); setDraggedTool("area");
setDraggedAreaData(area); setDraggedAreaData(area);
@ -1163,17 +1356,26 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
onDragEnd={() => { onDragEnd={() => {
setDraggedAreaData(null); setDraggedAreaData(null);
}} }}
className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" className={`rounded-lg border p-3 transition-all ${
isPlaced
? "bg-muted text-muted-foreground cursor-not-allowed opacity-60"
: "bg-background hover:bg-accent cursor-grab active:cursor-grabbing"
}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{area.AREANAME}</p> <p className="text-sm font-medium">{area.AREANAME}</p>
<p className="text-muted-foreground text-xs">{area.AREAKEY}</p> <p className="text-muted-foreground text-xs">{area.AREAKEY}</p>
</div> </div>
{isPlaced ? (
<span className="text-muted-foreground text-xs"></span>
) : (
<Grid3x3 className="text-muted-foreground h-4 w-4" /> <Grid3x3 className="text-muted-foreground h-4 w-4" />
)}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>
@ -1199,19 +1401,34 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
locationType = "location-dest"; locationType = "location-dest";
} }
// Location이 이미 배치되었는지 확인
const isLocationPlaced = placedObjects.some(
(obj) =>
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey === location.LOCAKEY,
);
return ( return (
<div <div
key={location.LOCAKEY} key={location.LOCAKEY}
draggable draggable={!isLocationPlaced}
onDragStart={() => { onDragStart={() => {
// Location 정보를 임시 저장 if (!isLocationPlaced) {
setDraggedTool(locationType); setDraggedTool(locationType);
setDraggedLocationData(location); setDraggedLocationData(location);
}
}} }}
onDragEnd={() => { onDragEnd={() => {
setDraggedLocationData(null); setDraggedLocationData(null);
}} }}
className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" className={`rounded-lg border p-3 transition-all ${
isLocationPlaced
? "bg-muted text-muted-foreground cursor-not-allowed opacity-60"
: "bg-background hover:bg-accent cursor-grab active:cursor-grabbing"
}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
@ -1221,7 +1438,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<span className="bg-muted rounded px-1.5 py-0.5">{location.LOCTYPE}</span> <span className="bg-muted rounded px-1.5 py-0.5">{location.LOCTYPE}</span>
</div> </div>
</div> </div>
{isLocationPlaced ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Package className="text-muted-foreground h-4 w-4" /> <Package className="text-muted-foreground h-4 w-4" />
)}
</div> </div>
</div> </div>
); );
@ -1331,49 +1552,46 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div> </div>
) : ( ) : (
<div className="space-y-2"> <Accordion type="single" collapsible className="w-full">
{materials.map((material, index) => ( {materials.map((material, index) => {
<div const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
key={`${material.STKKEY}-${index}`} const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
className="bg-muted hover:bg-accent rounded-lg border p-3 transition-colors" const displayColumns = hierarchyConfig?.material?.displayColumns || [];
>
<div className="mb-2 flex items-start justify-between"> const layerValue = material[layerColumn] || index + 1;
<div className="flex-1"> const keyValue = material[keyColumn] || `자재 ${index + 1}`;
<p className="text-sm font-medium">{material.STKKEY}</p>
<p className="text-muted-foreground mt-0.5 text-xs"> return (
: {material.LOLAYER} | Area: {material.AREAKEY} <AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b">
<AccordionTrigger className="px-2 py-3 hover:no-underline">
<div className="flex w-full items-center justify-between pr-2">
<span className="text-sm font-medium"> {layerValue}</span>
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{displayColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
</p> </p>
</div> ) : (
</div> <div className="space-y-1.5">
<div className="text-muted-foreground grid grid-cols-2 gap-2 text-xs"> {displayColumns.map((item) => (
{material.STKWIDT && ( <div key={item.column} className="flex justify-between gap-2 text-xs">
<div> <span className="text-muted-foreground shrink-0">{item.label}:</span>
: <span className="font-medium">{material.STKWIDT}</span> <span className="text-right font-medium break-all">
</div> {material[item.column] || "-"}
)} </span>
{material.STKLENG && (
<div>
: <span className="font-medium">{material.STKLENG}</span>
</div>
)}
{material.STKHEIG && (
<div>
: <span className="font-medium">{material.STKHEIG}</span>
</div>
)}
{material.STKWEIG && (
<div>
: <span className="font-medium">{material.STKWEIG}</span>
</div>
)}
</div>
{material.STKRMKS && (
<p className="text-muted-foreground mt-2 text-xs italic">{material.STKRMKS}</p>
)}
</div> </div>
))} ))}
</div> </div>
)} )}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</div> </div>
) : selectedObject ? ( ) : selectedObject ? (
<div className="p-4"> <div className="p-4">

View File

@ -34,6 +34,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const [showInfoPanel, setShowInfoPanel] = useState(false); const [showInfoPanel, setShowInfoPanel] = useState(false);
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null); const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
const [layoutName, setLayoutName] = useState<string>(""); const [layoutName, setLayoutName] = useState<string>("");
const [hierarchyConfig, setHierarchyConfig] = useState<any>(null);
// 검색 및 필터 // 검색 및 필터
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -49,14 +50,25 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
if (response.success && response.data) { if (response.success && response.data) {
const { layout, objects } = response.data; const { layout, objects } = response.data;
// 레이아웃 정보 저장 // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layoutName); setLayoutName(layout.layout_name || layout.layoutName);
setExternalDbConnectionId(layout.externalDbConnectionId); setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
// hierarchy_config 저장
if (layout.hierarchy_config) {
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
}
// 객체 데이터 변환 // 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => ({ const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id, id: obj.id,
type: obj.object_type, type: objectType,
name: obj.object_name, name: obj.object_name,
position: { position: {
x: parseFloat(obj.position_x), x: parseFloat(obj.position_x),
@ -69,7 +81,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
z: parseFloat(obj.size_z), z: parseFloat(obj.size_z),
}, },
rotation: obj.rotation ? parseFloat(obj.rotation) : 0, rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color, color: getObjectColor(objectType), // 타입별 기본 색상 사용
areaKey: obj.area_key, areaKey: obj.area_key,
locaKey: obj.loca_key, locaKey: obj.loca_key,
locType: obj.loc_type, locType: obj.loc_type,
@ -81,7 +93,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
displayOrder: obj.display_order, displayOrder: obj.display_order,
locked: obj.locked, locked: obj.locked,
visible: obj.visible !== false, visible: obj.visible !== false,
})); };
});
setPlacedObjects(loadedObjects); setPlacedObjects(loadedObjects);
} else { } else {
@ -101,16 +114,30 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}; };
loadLayout(); loadLayout();
}, [layoutId, toast]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 - 무한 루프 방지
// Location의 자재 목록 로드 // Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
if (!hierarchyConfig?.material) {
console.warn("hierarchyConfig.material이 없습니다. 자재 로드를 건너뜁니다.");
return;
}
try { try {
setLoadingMaterials(true); setLoadingMaterials(true);
setShowInfoPanel(true); setShowInfoPanel(true);
const response = await getMaterials(externalDbConnectionId, locaKey);
const response = await getMaterials(externalDbConnectionId, {
tableName: hierarchyConfig.material.tableName,
keyColumn: hierarchyConfig.material.keyColumn,
locationKeyColumn: hierarchyConfig.material.locationKeyColumn,
layerColumn: hierarchyConfig.material.layerColumn,
locaKey: locaKey,
});
if (response.success && response.data) { if (response.success && response.data) {
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0));
setMaterials(sortedMaterials); setMaterials(sortedMaterials);
} else { } else {
setMaterials([]); setMaterials([]);
@ -196,6 +223,49 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}); });
}, [placedObjects, filterType, searchQuery]); }, [placedObjects, filterType, searchQuery]);
// 객체 타입별 기본 색상 (useMemo로 최적화)
const getObjectColor = useMemo(() => {
return (type: string): string => {
const colorMap: Record<string, string> = {
area: "#3b82f6", // 파란색
"location-bed": "#2563eb", // 진한 파란색
"location-stp": "#6b7280", // 회색
"location-temp": "#f59e0b", // 주황색
"location-dest": "#10b981", // 초록색
"crane-mobile": "#8b5cf6", // 보라색
rack: "#ef4444", // 빨간색
};
return colorMap[type] || "#3b82f6";
};
}, []);
// 3D 캔버스용 placements 변환 (useMemo로 최적화)
const canvasPlacements = useMemo(() => {
return placedObjects.map((obj) => ({
id: obj.id,
name: obj.name,
position_x: obj.position.x,
position_y: obj.position.y,
position_z: obj.position.z,
size_x: obj.size.x,
size_y: obj.size.y,
size_z: obj.size.z,
color: obj.color,
data_source_type: obj.type,
material_count: obj.materialCount,
material_preview_height: obj.materialPreview?.height,
yard_layout_id: undefined,
material_code: null,
material_name: null,
quantity: null,
unit: null,
data_source_config: undefined,
data_binding: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
}, [placedObjects]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -217,13 +287,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{/* 메인 영역 */} {/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */} {/* 좌측: 검색/필터 */}
<div className="flex h-full w-[20%] flex-col border-r"> <div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* 검색 */} {/* 검색 */}
<div> <div>
<Label className="mb-2 block text-sm font-semibold"></Label> <Label className="mb-2 block text-sm font-semibold"></Label>
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@ -234,7 +304,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0" className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
onClick={() => setSearchQuery("")} onClick={() => setSearchQuery("")}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@ -281,9 +351,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{/* 객체 목록 */} {/* 객체 목록 */}
<div className="flex-1 overflow-y-auto border-t p-4"> <div className="flex-1 overflow-y-auto border-t p-4">
<Label className="mb-2 block text-sm font-semibold"> <Label className="mb-2 block text-sm font-semibold"> ({filteredObjects.length})</Label>
({filteredObjects.length})
</Label>
{filteredObjects.length === 0 ? ( {filteredObjects.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm"> <div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
@ -306,9 +374,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
key={obj.id} key={obj.id}
onClick={() => handleObjectClick(obj.id)} onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
? "ring-primary bg-primary/5 ring-2"
: "hover:shadow-sm"
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -317,7 +383,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs"> <div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span <span
className="inline-block h-2 w-2 rounded-full" className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }} style={{ backgroundColor: getObjectColor(obj.type) }}
/> />
<span>{typeLabel}</span> <span>{typeLabel}</span>
</div> </div>
@ -354,33 +420,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<div className="relative flex-1"> <div className="relative flex-1">
{!isLoading && ( {!isLoading && (
<Yard3DCanvas <Yard3DCanvas
placements={useMemo( placements={canvasPlacements}
() =>
placedObjects.map((obj) => ({
id: obj.id,
name: obj.name,
position_x: obj.position.x,
position_y: obj.position.y,
position_z: obj.position.z,
size_x: obj.size.x,
size_y: obj.size.y,
size_z: obj.size.z,
color: obj.color,
data_source_type: obj.type,
material_count: obj.materialCount,
material_preview_height: obj.materialPreview?.height,
yard_layout_id: undefined,
material_code: null,
material_name: null,
quantity: null,
unit: null,
data_source_config: undefined,
data_binding: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})),
[placedObjects],
)}
selectedPlacementId={selectedObject?.id || null} selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)} onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
focusOnPlacementId={null} focusOnPlacementId={null}
@ -390,18 +430,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
{/* 우측: 정보 패널 */} {/* 우측: 정보 패널 */}
{showInfoPanel && selectedObject && ( <div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
<div className="h-full w-[25%] overflow-y-auto border-l"> {selectedObject ? (
<div className="p-4"> <div className="p-4">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4">
<div>
<h3 className="text-lg font-semibold"> </h3> <h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs">{selectedObject.name}</p> <p className="text-muted-foreground text-xs">{selectedObject.name}</p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setShowInfoPanel(false)}>
<X className="h-4 w-4" />
</Button>
</div>
{/* 기본 정보 */} {/* 기본 정보 */}
<div className="bg-muted space-y-3 rounded-lg p-3"> <div className="bg-muted space-y-3 rounded-lg p-3">
@ -429,73 +464,75 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)} )}
</div> </div>
{/* 자재 목록 (Location인 경우) */} {/* 자재 목록 (Location인 경우) - 아코디언 */}
{(selectedObject.type === "location-bed" || {(selectedObject.type === "location-bed" ||
selectedObject.type === "location-stp" || selectedObject.type === "location-stp" ||
selectedObject.type === "location-temp" || selectedObject.type === "location-temp" ||
selectedObject.type === "location-dest") && ( selectedObject.type === "location-dest") && (
<div className="mt-4"> <div className="mt-4">
<Label className="mb-2 block text-sm font-semibold"> </Label>
{loadingMaterials ? ( {loadingMaterials ? (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" /> <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div> </div>
) : materials.length === 0 ? ( ) : materials.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm"> <div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{externalDbConnectionId {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
? "자재가 없습니다"
: "외부 DB 연결이 설정되지 않았습니다"}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{materials.map((material, index) => ( <Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
<div {materials.map((material, index) => {
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
return (
<details
key={`${material.STKKEY}-${index}`} key={`${material.STKKEY}-${index}`}
className="bg-muted hover:bg-accent rounded-lg border p-3 transition-colors" className="bg-muted group hover:bg-accent rounded-lg border transition-colors"
> >
<div className="mb-2 flex items-start justify-between"> <summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{material.STKKEY}</p> <div className="flex items-center gap-2">
<p className="text-muted-foreground mt-0.5 text-xs"> <span className="font-semibold">
: {material.LOLAYER} | Area: {material.AREAKEY} {material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
</p> </span>
</div> {displayColumns[0] && (
</div> <span className="text-muted-foreground text-xs">
<div className="text-muted-foreground grid grid-cols-2 gap-2 text-xs"> {material[displayColumns[0].column]}
{material.STKWIDT && ( </span>
<div>
: <span className="font-medium">{material.STKWIDT}</span>
</div>
)}
{material.STKLENG && (
<div>
: <span className="font-medium">{material.STKLENG}</span>
</div>
)}
{material.STKHEIG && (
<div>
: <span className="font-medium">{material.STKHEIG}</span>
</div>
)}
{material.STKWEIG && (
<div>
: <span className="font-medium">{material.STKWEIG}</span>
</div>
)} )}
</div> </div>
{material.STKRMKS && ( </div>
<p className="text-muted-foreground mt-2 text-xs italic">{material.STKRMKS}</p> <svg
)} className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="space-y-2 border-t p-3 pt-3">
{displayColumns.map((colConfig: any) => (
<div key={colConfig.column} className="flex justify-between text-xs">
<span className="text-muted-foreground">{colConfig.label}:</span>
<span className="font-medium">{material[colConfig.column] || "-"}</span>
</div> </div>
))} ))}
</div> </div>
</details>
);
})}
</div>
)} )}
</div> </div>
)} )}
</div> </div>
) : (
<div className="flex h-full items-center justify-center p-4">
<p className="text-muted-foreground text-sm"> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -0,0 +1,410 @@
# 디지털 트윈 동적 계층 구조 마이그레이션 가이드
## 개요
**기존 구조**: Area(구역) → Location(위치) 고정 2단계
**신규 구조**: 동적 N-Level 계층 (영역 → 하위 영역 → ... → 자재)
---
## 1. 데이터베이스 마이그레이션
### 실행 방법
```bash
# PostgreSQL 컨테이너 접속
docker exec -it pms-db psql -U postgres -d erp
# 마이그레이션 실행
\i db/migrations/042_refactor_digital_twin_hierarchy.sql
```
### 변경 사항
- `digital_twin_layout` 테이블에 `hierarchy_config` JSONB 컬럼 추가
- 기존 테이블 매핑 컬럼들 제거 (warehouse_table_name, area_table_name 등)
- `digital_twin_objects` 테이블에 계층 관련 컬럼 추가:
- `hierarchy_level`: 계층 레벨 (1, 2, 3, ...)
- `parent_key`: 부모 객체의 외부 DB 키
- `external_key`: 자신의 외부 DB 키
---
## 2. 백엔드 API 변경 사항
### 신규 API 엔드포인트
#### 전체 계층 데이터 조회
```
POST /api/digital-twin/data/hierarchy
Request Body:
{
"externalDbConnectionId": 15,
"hierarchyConfig": "{...}" // JSON 문자열
}
Response:
{
"success": true,
"data": {
"warehouse": [...],
"levels": [
{ "level": 1, "name": "구역", "data": [...] },
{ "level": 2, "name": "위치", "data": [...] }
],
"materials": [
{ "location_key": "LOC001", "count": 150 }
]
}
}
```
#### 특정 부모의 하위 데이터 조회
```
POST /api/digital-twin/data/children
Request Body:
{
"externalDbConnectionId": 15,
"hierarchyConfig": "{...}",
"parentLevel": 1,
"parentKey": "AREA001"
}
Response:
{
"success": true,
"data": [...] // 다음 레벨 데이터
}
```
### 레거시 API (호환성 유지)
- `/api/digital-twin/data/warehouses` (GET)
- `/api/digital-twin/data/areas` (GET)
- `/api/digital-twin/data/locations` (GET)
- `/api/digital-twin/data/materials` (GET)
- `/api/digital-twin/data/material-counts` (POST로 변경)
---
## 3. 프론트엔드 변경 사항
### 새로운 컴포넌트
#### `HierarchyConfigPanel.tsx`
동적 계층 구조 설정 UI
**사용 방법:**
```tsx
import HierarchyConfigPanel from "./HierarchyConfigPanel";
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={setHierarchyConfig}
availableTables={availableTables}
onLoadTables={loadTablesFromDb}
onLoadColumns={loadColumnsFromTable}
/>
```
### 계층 구조 설정 예시
```json
{
"warehouse": {
"tableName": "MWARMA",
"keyColumn": "WAREKEY",
"nameColumn": "WARENAME"
},
"levels": [
{
"level": 1,
"name": "구역",
"tableName": "MAREMA",
"keyColumn": "AREAKEY",
"nameColumn": "AREANAME",
"parentKeyColumn": "WAREKEY",
"objectTypes": ["area"]
},
{
"level": 2,
"name": "위치",
"tableName": "MLOCMA",
"keyColumn": "LOCAKEY",
"nameColumn": "LOCANAME",
"parentKeyColumn": "AREAKEY",
"typeColumn": "LOCTYPE",
"objectTypes": ["location-bed", "location-stp"]
}
],
"material": {
"tableName": "WSTKKY",
"keyColumn": "STKKEY",
"locationKeyColumn": "LOCAKEY",
"layerColumn": "LOLAYER",
"quantityColumn": "STKQUAN"
}
}
```
---
## 4. 공간적 종속성 (Spatial Containment)
### 새로운 유틸리티: `spatialContainment.ts`
#### 주요 함수
**1. 포함 여부 확인**
```typescript
import { isContainedIn } from "./spatialContainment";
const isValid = isContainedIn(childObject, parentObject);
// 자식 객체가 부모 객체 내부에 있는지 AABB로 검증
```
**2. 유효한 부모 찾기**
```typescript
import { findValidParent } from "./spatialContainment";
const parent = findValidParent(draggedChild, allObjects, hierarchyLevels);
// 드래그 중인 자식 객체를 포함하는 부모 객체 자동 감지
```
**3. 검증**
```typescript
import { validateSpatialContainment } from "./spatialContainment";
const result = validateSpatialContainment(child, allObjects);
if (!result.valid) {
alert("하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다!");
}
```
**4. 그룹 이동 (부모 이동 시 자식도 함께)**
```typescript
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
// 부모 객체 이동 시
const updatedChildren = updateChildrenPositions(
parentObject,
oldPosition,
newPosition,
allObjects
);
// 모든 하위 자손(재귀) 가져오기
const descendants = getAllDescendants(parentId, allObjects);
```
---
## 5. DigitalTwinEditor 통합 방법
### Step 1: HierarchyConfigPanel 추가
```tsx
// DigitalTwinEditor.tsx
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
// 좌측 사이드바에 추가
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={setHierarchyConfig}
availableTables={availableTables}
onLoadTables={loadTables}
onLoadColumns={loadColumns}
/>
```
### Step 2: 계층 데이터 로드
```tsx
import { getHierarchyData, getChildrenData } from "@/lib/api/digitalTwin";
const loadHierarchyData = async () => {
if (!selectedDbConnection || !hierarchyConfig) return;
const response = await getHierarchyData(selectedDbConnection, hierarchyConfig);
if (response.success && response.data) {
// 창고 데이터
setWarehouses(response.data.warehouse);
// 각 레벨 데이터
response.data.levels.forEach((level) => {
if (level.level === 1) {
setAvailableAreas(level.data);
} else if (level.level === 2) {
setAvailableLocations(level.data);
}
// ... 추가 레벨
});
// 자재 개수
setMaterialCounts(response.data.materials);
}
};
```
### Step 3: Yard3DCanvas에서 검증
```tsx
// Yard3DCanvas.tsx 또는 DigitalTwinEditor.tsx
import { validateSpatialContainment } from "./spatialContainment";
const handleObjectDrop = (droppedObject: PlacedObject) => {
const result = validateSpatialContainment(
{
id: droppedObject.id,
position: droppedObject.position,
size: droppedObject.size,
hierarchyLevel: droppedObject.hierarchyLevel || 1,
parentId: droppedObject.parentId,
},
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}))
);
if (!result.valid) {
toast({
variant: "destructive",
title: "배치 오류",
description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.",
});
return; // 배치 취소
}
// 유효하면 부모 ID 업데이트
droppedObject.parentId = result.parent?.id;
// 상태 업데이트
setPlacedObjects([...placedObjects, droppedObject]);
};
```
### Step 4: 그룹 이동 구현
```tsx
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
const handleObjectMove = (
movedObject: PlacedObject,
oldPosition: { x: number; y: number; z: number },
newPosition: { x: number; y: number; z: number }
) => {
// 이동한 객체 업데이트
const updatedObjects = placedObjects.map((obj) =>
obj.id === movedObject.id
? { ...obj, position: newPosition }
: obj
);
// 모든 하위 자손 가져오기
const descendants = getAllDescendants(
movedObject.id,
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}))
);
// 하위 자손들도 같이 이동
const delta = {
x: newPosition.x - oldPosition.x,
y: newPosition.y - oldPosition.y,
z: newPosition.z - oldPosition.z,
};
descendants.forEach((descendant) => {
const index = updatedObjects.findIndex((obj) => obj.id === descendant.id);
if (index !== -1) {
updatedObjects[index].position = {
x: updatedObjects[index].position.x + delta.x,
y: updatedObjects[index].position.y + delta.y,
z: updatedObjects[index].position.z + delta.z,
};
}
});
setPlacedObjects(updatedObjects);
};
```
---
## 6. 테스트 시나리오
### 테스트 1: 계층 구조 설정
1. 외부 DB 선택
2. 창고 테이블 선택 및 컬럼 매핑
3. 레벨 추가 (레벨 1: 구역, 레벨 2: 위치)
4. 각 레벨의 테이블 및 컬럼 매핑
5. 자재 테이블 설정
6. "저장" 클릭하여 `hierarchy_config` 저장
### 테스트 2: 데이터 로드
1. 계층 구조 설정 완료 후
2. 창고 선택
3. 각 레벨 데이터가 좌측 패널에 표시되는지 확인
4. 자재 개수가 올바르게 표시되는지 확인
### 테스트 3: 3D 배치 및 공간적 종속성
1. 레벨 1 (구역) 객체를 3D 캔버스에 드래그앤드롭
2. 레벨 2 (위치) 객체를 레벨 1 객체 **내부**에 드래그앤드롭 → 성공
3. 레벨 2 객체를 레벨 1 객체 **외부**에 드롭 → 오류 메시지 표시
### 테스트 4: 그룹 이동
1. 레벨 1 객체를 이동
2. 해당 레벨 1 객체의 모든 하위 객체(레벨 2, 3, ...)도 같이 이동하는지 확인
3. 부모-자식 관계가 유지되는지 확인
### 테스트 5: 레이아웃 저장/로드
1. 위 단계를 완료한 후 "저장" 클릭
2. 페이지 새로고침
3. 레이아웃을 다시 로드하여 계층 구조 및 객체 위치가 복원되는지 확인
---
## 7. 마이그레이션 체크리스트
- [ ] DB 마이그레이션 실행 (042_refactor_digital_twin_hierarchy.sql)
- [ ] 백엔드 API 테스트 (Postman/cURL)
- [ ] `HierarchyConfigPanel` 컴포넌트 통합
- [ ] `spatialContainment.ts` 유틸리티 통합
- [ ] `DigitalTwinEditor`에서 계층 데이터 로드 구현
- [ ] `Yard3DCanvas`에서 공간적 종속성 검증 구현
- [ ] 그룹 이동 기능 구현
- [ ] 모든 테스트 시나리오 통과
- [ ] 레거시 API와의 호환성 확인
---
## 8. 주의사항
1. **기존 레이아웃 데이터**: 마이그레이션 전 기존 레이아웃이 있다면 백업 필요
2. **컬럼 매핑 검증**: 외부 DB 테이블의 컬럼명이 변경될 수 있으므로 auto-mapping 로직 필수
3. **성능**: N-Level이 3단계 이상 깊어지면 재귀 쿼리 성능 모니터링 필요
4. **권한**: 외부 DB에 대한 읽기 권한 확인
---
## 9. 향후 개선 사항
1. **드래그 중 실시간 검증**: 드래그하는 동안 부모 영역 하이라이트
2. **시각적 피드백**: 유효한 배치 위치를 그리드에 색상으로 표시
3. **계층 구조 시각화**: 좌측 패널에 트리 구조로 표시
4. **Undo/Redo**: 객체 배치 실행 취소 기능
5. **스냅 가이드**: 부모 영역 테두리에 스냅 가이드라인 표시
---
**작성일**: 2025-11-20
**작성자**: AI Assistant

View File

@ -0,0 +1,559 @@
"use client";
import { useState, useEffect } 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 { Loader2, Plus, Trash2, GripVertical } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
// 계층 레벨 설정 인터페이스
export interface HierarchyLevel {
level: number;
name: string;
tableName: string;
keyColumn: string;
nameColumn: string;
parentKeyColumn: string;
typeColumn?: string;
objectTypes: string[];
}
// 전체 계층 구조 설정
export interface HierarchyConfig {
warehouseKey: string; // 이 레이아웃이 속한 창고 키 (예: "DY99")
warehouse?: {
tableName: string; // 창고 테이블명 (예: "MWARMA")
keyColumn: string;
nameColumn: string;
};
levels: HierarchyLevel[];
material?: {
tableName: string;
keyColumn: string;
locationKeyColumn: string;
layerColumn?: string;
quantityColumn?: string;
displayColumns?: Array<{ column: string; label: string }>; // 우측 패널에 표시할 컬럼들 (컬럼명 + 표시명)
};
}
interface HierarchyConfigPanelProps {
externalDbConnectionId: number | null;
hierarchyConfig: HierarchyConfig | null;
onHierarchyConfigChange: (config: HierarchyConfig) => void;
availableTables: string[];
onLoadTables: () => Promise<void>;
onLoadColumns: (tableName: string) => Promise<string[]>;
}
export default function HierarchyConfigPanel({
externalDbConnectionId,
hierarchyConfig,
onHierarchyConfigChange,
availableTables,
onLoadTables,
onLoadColumns,
}: HierarchyConfigPanelProps) {
const [localConfig, setLocalConfig] = useState<HierarchyConfig>(
hierarchyConfig || {
warehouseKey: "",
levels: [],
},
);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
// 외부에서 변경된 경우 동기화
useEffect(() => {
if (hierarchyConfig) {
setLocalConfig(hierarchyConfig);
}
}, [hierarchyConfig]);
// 테이블 선택 시 컬럼 로드
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵
setLoadingColumns(true);
try {
const columns = await onLoadColumns(tableName);
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
} catch (error) {
console.error("컬럼 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
// 레벨 추가
const handleAddLevel = () => {
const maxLevel = localConfig.levels.length > 0 ? Math.max(...localConfig.levels.map((l) => l.level)) : 0;
const newLevel: HierarchyLevel = {
level: maxLevel + 1,
name: `레벨 ${maxLevel + 1}`,
tableName: "",
keyColumn: "",
nameColumn: "",
parentKeyColumn: "",
objectTypes: [],
};
const newConfig = {
...localConfig,
levels: [...localConfig.levels, newLevel],
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 레벨 삭제
const handleRemoveLevel = (level: number) => {
const newConfig = {
...localConfig,
levels: localConfig.levels.filter((l) => l.level !== level),
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 레벨 설정 변경
const handleLevelChange = (level: number, field: keyof HierarchyLevel, value: any) => {
const newConfig = {
...localConfig,
levels: localConfig.levels.map((l) => (l.level === level ? { ...l, [field]: value } : l)),
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 자재 설정 변경
const handleMaterialChange = (field: keyof NonNullable<HierarchyConfig["material"]>, value: string) => {
const newConfig = {
...localConfig,
material: {
...localConfig.material,
[field]: value,
} as NonNullable<HierarchyConfig["material"]>,
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 창고 설정 변경
const handleWarehouseChange = (field: keyof NonNullable<HierarchyConfig["warehouse"]>, value: string) => {
const newWarehouse = {
...localConfig.warehouse,
[field]: value,
} as NonNullable<HierarchyConfig["warehouse"]>;
setLocalConfig({ ...localConfig, warehouse: newWarehouse });
};
// 설정 적용
const handleApplyConfig = () => {
onHierarchyConfigChange(localConfig);
};
if (!externalDbConnectionId) {
return <div className="text-muted-foreground p-4 text-center text-sm"> DB를 </div>;
}
return (
<div className="space-y-4">
{/* 창고 설정 */}
<Card>
<CardHeader className="p-4 pb-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[10px]"> </CardDescription>
</CardHeader>
<CardContent className="space-y-2 p-4 pt-0">
{/* 창고 테이블 선택 */}
<div>
<Label className="text-[10px]"></Label>
<Select
value={localConfig.warehouse?.tableName || ""}
onValueChange={async (value) => {
handleWarehouseChange("tableName", value);
await handleTableChange(value, "warehouse");
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-[10px]">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 창고 컬럼 매핑 */}
{localConfig.warehouse?.tableName && columnsCache[localConfig.warehouse.tableName] && (
<div className="space-y-2">
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={localConfig.warehouse.keyColumn || ""}
onValueChange={(value) => handleWarehouseChange("keyColumn", value)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-[10px]">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localConfig.warehouse.nameColumn || ""}
onValueChange={(value) => handleWarehouseChange("nameColumn", value)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-[10px]">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
{/* 계층 레벨 목록 */}
<Card>
<CardHeader className="p-4 pb-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[10px]">, </CardDescription>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{localConfig.levels.length === 0 && (
<div className="text-muted-foreground py-6 text-center text-xs"> </div>
)}
{localConfig.levels.map((level) => (
<Card key={level.level} className="border-muted">
<CardHeader className="flex flex-row items-center justify-between p-3">
<div className="flex items-center gap-2">
<GripVertical className="text-muted-foreground h-4 w-4" />
<Input
value={level.name}
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
className="h-7 w-32 text-xs"
placeholder="레벨명"
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveLevel(level.level)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</CardHeader>
<CardContent className="space-y-2 p-3 pt-0">
<div>
<Label className="text-[10px]"></Label>
<Select
value={level.tableName}
onValueChange={(val) => {
handleLevelChange(level.level, "tableName", val);
handleTableChange(val, level.level);
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{level.tableName && columnsCache[level.tableName] && (
<>
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={level.keyColumn}
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={level.nameColumn}
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={level.parentKeyColumn}
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="부모 키 컬럼" />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={level.typeColumn || "__none__"}
onValueChange={(val) =>
handleLevelChange(level.level, "typeColumn", val === "__none__" ? undefined : val)
}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="타입 컬럼 (선택)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</CardContent>
</Card>
))}
<Button variant="outline" size="sm" onClick={handleAddLevel} className="h-8 w-full text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</CardContent>
</Card>
{/* 자재 설정 */}
<Card>
<CardHeader className="p-4 pb-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[10px]"> </CardDescription>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
<div>
<Label className="text-[10px]"></Label>
<Select
value={localConfig.material?.tableName || ""}
onValueChange={(val) => {
handleMaterialChange("tableName", val);
handleTableChange(val, "material");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && (
<>
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={localConfig.material.keyColumn}
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localConfig.material.locationKeyColumn}
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={localConfig.material.layerColumn || "__none__"}
onValueChange={(val) => handleMaterialChange("layerColumn", val === "__none__" ? undefined : val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="레이어 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={localConfig.material.quantityColumn || "__none__"}
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="수량 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator className="my-3" />
{/* 표시 컬럼 선택 */}
<div>
<Label className="text-[10px]"> </Label>
<p className="text-muted-foreground mb-2 text-[9px]">
</p>
<div className="max-h-60 space-y-2 overflow-y-auto rounded border p-2">
{columnsCache[localConfig.material.tableName].map((col) => {
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col);
const isSelected = !!displayItem;
return (
<div key={col} className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
const currentDisplay = localConfig.material?.displayColumns || [];
const newDisplay = e.target.checked
? [...currentDisplay, { column: col, label: col }]
: currentDisplay.filter((d) => d.column !== col);
handleMaterialChange("displayColumns", newDisplay);
}}
className="h-3 w-3 shrink-0"
/>
<span className="w-20 shrink-0 text-[10px]">{col}</span>
{isSelected && (
<Input
value={displayItem?.label || col}
onChange={(e) => {
const currentDisplay = localConfig.material?.displayColumns || [];
const newDisplay = currentDisplay.map((d) =>
d.column === col ? { ...d, label: e.target.value } : d,
);
handleMaterialChange("displayColumns", newDisplay);
}}
placeholder="표시명 입력..."
className="h-6 flex-1 text-[10px]"
/>
)}
</div>
);
})}
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 적용 버튼 */}
<div className="flex justify-end">
<Button onClick={handleApplyConfig} className="h-10 gap-2 text-sm font-medium">
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,164 @@
/**
*
*
*
*/
export interface SpatialObject {
id: number;
position: { x: number; y: number; z: number };
size: { x: number; y: number; z: number };
hierarchyLevel: number;
parentId?: number;
parentKey?: string; // 외부 DB 키 (데이터 바인딩용)
}
/**
* A가 B (AABB)
*/
export function isContainedIn(child: SpatialObject, parent: SpatialObject): boolean {
// AABB (Axis-Aligned Bounding Box) 계산
const childMin = {
x: child.position.x - child.size.x / 2,
z: child.position.z - child.size.z / 2,
};
const childMax = {
x: child.position.x + child.size.x / 2,
z: child.position.z + child.size.z / 2,
};
const parentMin = {
x: parent.position.x - parent.size.x / 2,
z: parent.position.z - parent.size.z / 2,
};
const parentMax = {
x: parent.position.x + parent.size.x / 2,
z: parent.position.z + parent.size.z / 2,
};
// 자식 객체의 모든 모서리가 부모 객체 내부에 있어야 함 (XZ 평면에서)
return (
childMin.x >= parentMin.x &&
childMax.x <= parentMax.x &&
childMin.z >= parentMin.z &&
childMax.z <= parentMax.z
);
}
/**
*
* @param child
* @param allObjects
* @param hierarchyLevels (1, 2, 3, ...)
* @returns null
*/
export function findValidParent(
child: SpatialObject,
allObjects: SpatialObject[],
hierarchyLevels: number
): SpatialObject | null {
// 최상위 레벨(레벨 1)은 부모가 없음
if (child.hierarchyLevel === 1) {
return null;
}
// 부모 레벨 (자신보다 1단계 위)
const parentLevel = child.hierarchyLevel - 1;
// 부모 레벨의 모든 객체 중에서 포함하는 객체 찾기
const possibleParents = allObjects.filter(
(obj) => obj.hierarchyLevel === parentLevel
);
for (const parent of possibleParents) {
if (isContainedIn(child, parent)) {
return parent;
}
}
// 포함하는 부모가 없으면 null
return null;
}
/**
*
* @param child
* @param allObjects
* @returns { valid: boolean, parent: SpatialObject | null }
*/
export function validateSpatialContainment(
child: SpatialObject,
allObjects: SpatialObject[]
): { valid: boolean; parent: SpatialObject | null } {
// 최상위 레벨은 항상 유효
if (child.hierarchyLevel === 1) {
return { valid: true, parent: null };
}
const parent = findValidParent(child, allObjects, child.hierarchyLevel);
return {
valid: parent !== null,
parent: parent,
};
}
/**
*
* @param parent
* @param oldPosition
* @param newPosition
* @param allObjects
* @returns
*/
export function updateChildrenPositions(
parent: SpatialObject,
oldPosition: { x: number; y: number; z: number },
newPosition: { x: number; y: number; z: number },
allObjects: SpatialObject[]
): SpatialObject[] {
const delta = {
x: newPosition.x - oldPosition.x,
y: newPosition.y - oldPosition.y,
z: newPosition.z - oldPosition.z,
};
// 직계 자식 (부모 ID가 일치하는 객체)
const directChildren = allObjects.filter(
(obj) => obj.parentId === parent.id
);
// 자식들의 위치 업데이트
return directChildren.map((child) => ({
...child,
position: {
x: child.position.x + delta.x,
y: child.position.y + delta.y,
z: child.position.z + delta.z,
},
}));
}
/**
* ()
* @param parentId ID
* @param allObjects
* @returns
*/
export function getAllDescendants(
parentId: number,
allObjects: SpatialObject[]
): SpatialObject[] {
const directChildren = allObjects.filter((obj) => obj.parentId === parentId);
let descendants = [...directChildren];
// 재귀적으로 손자, 증손자... 찾기
for (const child of directChildren) {
const grandChildren = getAllDescendants(child.id, allObjects);
descendants = [...descendants, ...grandChildren];
}
return descendants;
}

View File

@ -424,7 +424,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const firstCoord = row.coordinates[0]; const firstCoord = row.coordinates[0];
if (Array.isArray(firstCoord) && firstCoord.length === 2) { if (Array.isArray(firstCoord) && firstCoord.length === 2) {
polygons.push({ polygons.push({
id: row.id || row.code || `polygon-${index}`, id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
name: row.name || row.title || `영역 ${index + 1}`, name: row.name || row.title || `영역 ${index + 1}`,
coordinates: row.coordinates as [number, number][], coordinates: row.coordinates as [number, number][],
status: row.status || row.level, status: row.status || row.level,
@ -499,9 +499,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
markers.push({ markers.push({
// 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용 // 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용
// - row.id / row.code가 있으면 그 값을 사용 // 중복 방지를 위해 sourceName과 index를 조합하여 고유 ID 생성
// - 없으면 sourceName과 index 조합으로 고정 ID 생성 id: `${sourceName}-${row.id || row.code || "marker"}-${index}`,
id: row.id || row.code || `${sourceName}-marker-${index}`,
lat: Number(lat), lat: Number(lat),
lng: Number(lng), lng: Number(lng),
latitude: Number(lat), latitude: Number(lat),
@ -1264,14 +1263,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{/* 마커 렌더링 */} {/* 마커 렌더링 */}
{markers.map((marker) => { {markers.map((marker) => {
// 첫 번째 데이터 소스의 마커 종류 가져오 // 마커의 소스에 해당하는 데이터 소스 찾
const firstDataSource = dataSources?.[0]; const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
const markerType = firstDataSource?.markerType || "circle"; const markerType = sourceDataSource?.markerType || "circle";
let markerIcon: any; let markerIcon: any;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const L = require("leaflet"); const L = require("leaflet");
const heading = marker.heading || 0; // heading이 없거나 0일 때 기본값 90(동쪽/오른쪽)으로 설정하여 처음에 오른쪽을 보게 함
const heading = marker.heading || 90;
if (markerType === "arrow") { if (markerType === "arrow") {
// 화살표 마커 // 화살표 마커
@ -1303,6 +1303,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}); });
} else if (markerType === "truck") { } else if (markerType === "truck") {
// 트럭 마커 // 트럭 마커
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
const rotation = heading - 90;
markerIcon = L.divIcon({ markerIcon = L.divIcon({
className: "custom-truck-marker", className: "custom-truck-marker",
html: ` html: `
@ -1312,11 +1315,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transform: translate(-50%, -50%) rotate(${heading}deg); transform: translate(-50%, -50%) rotate(${rotation}deg);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
"> ">
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"> <svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<g transform="rotate(-90 20 20)"> <g>
<!-- --> <!-- -->
<rect <rect
x="10" x="10"

View File

@ -377,8 +377,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
new Date().toISOString(); new Date().toISOString();
const alert: Alert = { const alert: Alert = {
id: row.id || row.alert_id || row.incidentId || row.eventId || // 중복 방지를 위해 소스명과 인덱스를 포함하여 고유 ID 생성
row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`, id: `${sourceName}-${index}-${row.id || row.alert_id || row.incidentId || row.eventId || row.code || row.subCode || Date.now()}`,
type, type,
severity, severity,
title, title,
@ -614,8 +614,9 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
<p className="text-sm"> </p> <p className="text-sm"> </p>
</div> </div>
) : ( ) : (
filteredAlerts.map((alert) => ( filteredAlerts.map((alert, idx) => (
<Card key={alert.id} className="p-2"> // key 중복 방지를 위해 인덱스 추가
<Card key={`${alert.id}-${idx}`} className="p-2">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-destructive/10 text-destructive" : alert.severity === "medium" ? "bg-warning/10 text-warning" : "bg-primary/10 text-primary"}`}> <div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-destructive/10 text-destructive" : alert.severity === "medium" ? "bg-warning/10 text-warning" : "bg-primary/10 text-primary"}`}>
{getTypeIcon(alert.type)} {getTypeIcon(alert.type)}

View File

@ -119,6 +119,25 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const [localHeight, setLocalHeight] = useState<string>(""); const [localHeight, setLocalHeight] = useState<string>("");
const [localWidth, setLocalWidth] = useState<string>(""); const [localWidth, setLocalWidth] = useState<string>("");
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
// 🆕 전체 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("전체 테이블 목록 로드 실패:", error);
}
};
loadAllTables();
}, []);
// 새로운 컴포넌트 시스템의 webType 동기화 // 새로운 컴포넌트 시스템의 webType 동기화
useEffect(() => { useEffect(() => {
if (selectedComponent?.type === "component") { if (selectedComponent?.type === "component") {
@ -279,14 +298,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}; };
const handleConfigChange = (newConfig: any) => { const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig); // 기존 config와 병합하여 다른 속성 유지
const currentConfig = selectedComponent.componentConfig?.config || {};
const mergedConfig = { ...currentConfig, ...newConfig };
onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig);
}; };
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId = const componentId =
selectedComponent.componentType || // ⭐ section-card 등 selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id; selectedComponent.componentConfig?.id ||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
if (componentId) { if (componentId) {
const definition = ComponentRegistry.getComponent(componentId); const definition = ComponentRegistry.getComponent(componentId);
@ -318,7 +341,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Settings className="h-4 w-4 text-primary" /> <Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3> <h3 className="text-sm font-semibold">{definition.name} </h3>
</div> </div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} /> <ConfigPanelComponent
config={config}
onChange={handleConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</div> </div>
); );
}; };
@ -994,6 +1024,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
); );
} }
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
const configPanelContent = renderComponentConfigPanel();
if (configPanelContent) {
return configPanelContent;
}
}
// 현재 웹타입의 기본 입력 타입 추출 // 현재 웹타입의 기본 입력 타입 추출
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;

View File

@ -26,6 +26,7 @@ interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백 onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
screenId?: number; // 화면 ID 추가
} }
// 필터 타입별 연산자 // 필터 타입별 연산자
@ -69,7 +70,7 @@ interface ColumnFilterConfig {
selectOptions?: Array<{ label: string; value: string }>; selectOptions?: Array<{ label: string; value: string }>;
} }
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => { export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
const { getTable, selectedTableId } = useTableOptions(); const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined; const table = selectedTableId ? getTable(selectedTableId) : undefined;
@ -79,7 +80,10 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
// localStorage에서 저장된 필터 설정 불러오기 // localStorage에서 저장된 필터 설정 불러오기
useEffect(() => { useEffect(() => {
if (table?.columns && table?.tableName) { if (table?.columns && table?.tableName) {
const storageKey = `table_filters_${table.tableName}`; // 화면별로 독립적인 필터 설정 저장
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey); const savedFilters = localStorage.getItem(storageKey);
let filters: ColumnFilterConfig[]; let filters: ColumnFilterConfig[];
@ -192,9 +196,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
width: cf.width || 200, // 너비 포함 (기본 200px) width: cf.width || 200, // 너비 포함 (기본 200px)
})); }));
// localStorage에 저장 // localStorage에 저장 (화면별로 독립적)
if (table?.tableName) { if (table?.tableName) {
const storageKey = `table_filters_${table.tableName}`; const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters)); localStorage.setItem(storageKey, JSON.stringify(columnFilters));
} }
@ -216,9 +222,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
setColumnFilters(clearedFilters); setColumnFilters(clearedFilters);
setSelectAll(false); setSelectAll(false);
// localStorage에서 제거 // localStorage에서 제거 (화면별로 독립적)
if (table?.tableName) { if (table?.tableName) {
const storageKey = `table_filters_${table.tableName}`; const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
} }

View File

@ -91,9 +91,7 @@ export const deleteLayout = async (id: number): Promise<ApiResponse<void>> => {
// ========== 외부 DB 테이블 조회 API ========== // ========== 외부 DB 테이블 조회 API ==========
export const getTables = async ( export const getTables = async (connectionId: number): Promise<ApiResponse<Array<{ table_name: string }>>> => {
connectionId: number
): Promise<ApiResponse<Array<{ table_name: string }>>> => {
try { try {
const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`); const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`);
return response.data; return response.data;
@ -105,10 +103,7 @@ export const getTables = async (
} }
}; };
export const getTablePreview = async ( export const getTablePreview = async (connectionId: number, tableName: string): Promise<ApiResponse<any[]>> => {
connectionId: number,
tableName: string
): Promise<ApiResponse<any[]>> => {
try { try {
const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`); const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`);
return response.data; return response.data;
@ -123,7 +118,10 @@ export const getTablePreview = async (
// ========== 외부 DB 데이터 조회 API ========== // ========== 외부 DB 데이터 조회 API ==========
// 창고 목록 조회 // 창고 목록 조회
export const getWarehouses = async (externalDbConnectionId: number, tableName: string): Promise<ApiResponse<Warehouse[]>> => { export const getWarehouses = async (
externalDbConnectionId: number,
tableName: string,
): Promise<ApiResponse<Warehouse[]>> => {
try { try {
const response = await apiClient.get("/digital-twin/data/warehouses", { const response = await apiClient.get("/digital-twin/data/warehouses", {
params: { externalDbConnectionId, tableName }, params: { externalDbConnectionId, tableName },
@ -138,7 +136,11 @@ export const getWarehouses = async (externalDbConnectionId: number, tableName: s
}; };
// Area 목록 조회 // Area 목록 조회
export const getAreas = async (externalDbConnectionId: number, tableName: string, warehouseKey: string): Promise<ApiResponse<Area[]>> => { export const getAreas = async (
externalDbConnectionId: number,
tableName: string,
warehouseKey: string,
): Promise<ApiResponse<Area[]>> => {
try { try {
const response = await apiClient.get("/digital-twin/data/areas", { const response = await apiClient.get("/digital-twin/data/areas", {
params: { externalDbConnectionId, tableName, warehouseKey }, params: { externalDbConnectionId, tableName, warehouseKey },
@ -174,12 +176,24 @@ export const getLocations = async (
// 자재 목록 조회 (특정 Location) // 자재 목록 조회 (특정 Location)
export const getMaterials = async ( export const getMaterials = async (
externalDbConnectionId: number, externalDbConnectionId: number,
tableName: string, materialConfig: {
locaKey: string, tableName: string;
keyColumn: string;
locationKeyColumn: string;
layerColumn?: string;
locaKey: string;
},
): Promise<ApiResponse<MaterialData[]>> => { ): Promise<ApiResponse<MaterialData[]>> => {
try { try {
const response = await apiClient.get("/digital-twin/data/materials", { const response = await apiClient.get("/digital-twin/data/materials", {
params: { externalDbConnectionId, tableName, locaKey }, params: {
externalDbConnectionId,
tableName: materialConfig.tableName,
keyColumn: materialConfig.keyColumn,
locationKeyColumn: materialConfig.locationKeyColumn,
layerColumn: materialConfig.layerColumn,
locaKey: materialConfig.locaKey,
},
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
@ -197,12 +211,10 @@ export const getMaterialCounts = async (
locaKeys: string[], locaKeys: string[],
): Promise<ApiResponse<MaterialCount[]>> => { ): Promise<ApiResponse<MaterialCount[]>> => {
try { try {
const response = await apiClient.get("/digital-twin/data/material-counts", { const response = await apiClient.post("/digital-twin/data/material-counts", {
params: {
externalDbConnectionId, externalDbConnectionId,
tableName, tableName,
locaKeys: locaKeys.join(","), locationKeys: locaKeys,
},
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
@ -213,3 +225,59 @@ export const getMaterialCounts = async (
} }
}; };
// ========== 동적 계층 구조 API ==========
export interface HierarchyData {
warehouse: any[];
levels: Array<{
level: number;
name: string;
data: any[];
}>;
materials: Array<{
location_key: string;
count: number;
}>;
}
// 전체 계층 데이터 조회
export const getHierarchyData = async (
externalDbConnectionId: number,
hierarchyConfig: any,
): Promise<ApiResponse<HierarchyData>> => {
try {
const response = await apiClient.post("/digital-twin/data/hierarchy", {
externalDbConnectionId,
hierarchyConfig: JSON.stringify(hierarchyConfig),
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
};
// 특정 부모의 하위 데이터 조회
export const getChildrenData = async (
externalDbConnectionId: number,
hierarchyConfig: any,
parentLevel: number,
parentKey: string,
): Promise<ApiResponse<any[]>> => {
try {
const response = await apiClient.post("/digital-twin/data/children", {
externalDbConnectionId,
hierarchyConfig: JSON.stringify(hierarchyConfig),
parentLevel,
parentKey,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
};

View File

@ -150,7 +150,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const columnName = (component as any).columnName; const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
if ((inputType === "category" || webType === "category") && tableName && columnName) { // ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") {
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
try { try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id; const fieldName = columnName || component.id;
@ -213,6 +216,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. 새 컴포넌트 시스템에서 먼저 조회 // 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType); const newComponent = ComponentRegistry.getComponent(componentType);
// 🔍 디버깅: select-basic 조회 결과 확인
if (componentType === "select-basic") {
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
componentType,
found: !!newComponent,
componentId: component.id,
componentConfig: component.componentConfig,
});
}
if (newComponent) { if (newComponent) {
// 새 컴포넌트 시스템으로 렌더링 // 새 컴포넌트 시스템으로 렌더링
try { try {

View File

@ -50,11 +50,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
menuObjid, // 🆕 메뉴 OBJID menuObjid, // 🆕 메뉴 OBJID
...props ...props
}) => { }) => {
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
componentId: component.id,
componentType: (component as any).componentType,
columnName: (component as any).columnName,
"props.multiple": (props as any).multiple,
"componentConfig.multiple": componentConfig?.multiple,
});
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
const config = (props as any).webTypeConfig || componentConfig || {}; const config = (props as any).webTypeConfig || componentConfig || {};
// 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위
const isMultiple = (props as any).multiple ?? config?.multiple ?? false;
// 🔍 디버깅: config 및 multiple 확인
useEffect(() => {
console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 ==========");
console.log(" 컴포넌트 ID:", component.id);
console.log(" 최종 isMultiple 값:", isMultiple);
console.log(" ----------------------------------------");
console.log(" props.multiple:", (props as any).multiple);
console.log(" config.multiple:", config?.multiple);
console.log(" componentConfig.multiple:", componentConfig?.multiple);
console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple);
console.log(" ----------------------------------------");
console.log(" config 전체:", config);
console.log(" componentConfig 전체:", componentConfig);
console.log(" component.componentConfig 전체:", component.componentConfig);
console.log(" =======================================");
// 다중선택이 활성화되었는지 알림
if (isMultiple) {
console.log("✅ 다중선택 모드 활성화됨!");
} else {
console.log("❌ 단일선택 모드 (다중선택 비활성화)");
}
}, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]);
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
const webType = component.componentConfig?.webType || "select"; const webType = component.componentConfig?.webType || "select";
@ -62,8 +98,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
const [selectedLabel, setSelectedLabel] = useState(""); const [selectedLabel, setSelectedLabel] = useState("");
// multiselect의 경우 배열로 관리 // multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
const [selectedValues, setSelectedValues] = useState<string[]>([]); const [selectedValues, setSelectedValues] = useState<string[]>(() => {
const initialValue = externalValue || config?.value || "";
if (isMultiple && typeof initialValue === "string" && initialValue) {
return initialValue.split(",").map(v => v.trim()).filter(v => v);
}
return [];
});
// autocomplete의 경우 검색어 관리 // autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -96,6 +138,58 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
isFetching, isFetching,
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid); } = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
// 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
useEffect(() => {
if (webType === "category" && component.tableName && component.columnName) {
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
tableName: component.tableName,
columnName: component.columnName,
webType,
});
setIsLoadingCategories(true);
import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => {
getCategoryValues(component.tableName!, component.columnName!)
.then((response) => {
console.log("🔍 [SelectBasic] 카테고리 API 응답:", response);
if (response.success && response.data) {
console.log("🔍 [SelectBasic] 원본 데이터 샘플:", {
firstItem: response.data[0],
keys: response.data[0] ? Object.keys(response.data[0]) : [],
});
const activeValues = response.data.filter((v) => v.isActive !== false);
const options = activeValues.map((v) => ({
value: v.valueCode,
label: v.valueLabel || v.valueCode,
}));
console.log("✅ [SelectBasic] 카테고리 옵션 설정:", {
activeValuesCount: activeValues.length,
options,
sampleOption: options[0],
});
setCategoryOptions(options);
} else {
console.error("❌ [SelectBasic] 카테고리 응답 실패:", response);
}
})
.catch((error) => {
console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error);
})
.finally(() => {
setIsLoadingCategories(false);
});
});
}
}, [webType, component.tableName, component.columnName]);
// 디버깅: menuObjid가 제대로 전달되는지 확인 // 디버깅: menuObjid가 제대로 전달되는지 확인
useEffect(() => { useEffect(() => {
if (codeCategory && codeCategory !== "none") { if (codeCategory && codeCategory !== "none") {
@ -113,11 +207,42 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 외부 value prop 변경 시 selectedValue 업데이트 // 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => { useEffect(() => {
const newValue = externalValue || config?.value || ""; const newValue = externalValue || config?.value || "";
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
console.log("🔍 [SelectBasic] 외부 값 변경 감지:", {
componentId: component.id,
columnName: (component as any).columnName,
isMultiple,
newValue,
selectedValue,
selectedValues,
externalValue,
"config.value": config?.value,
});
// 다중선택 모드인 경우
if (isMultiple) {
if (typeof newValue === "string" && newValue) {
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
const currentValuesStr = selectedValues.join(",");
if (newValue !== currentValuesStr) {
console.log("✅ [SelectBasic] 다중선택 값 업데이트:", {
from: selectedValues,
to: values,
});
setSelectedValues(values);
}
} else if (!newValue && selectedValues.length > 0) {
console.log("✅ [SelectBasic] 다중선택 값 초기화");
setSelectedValues([]);
}
} else {
// 단일선택 모드인 경우
if (newValue !== selectedValue) { if (newValue !== selectedValue) {
setSelectedValue(newValue); setSelectedValue(newValue);
} }
}, [externalValue, config?.value]); }
}, [externalValue, config?.value, isMultiple]);
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
@ -128,7 +253,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
useEffect(() => { useEffect(() => {
const getAllOptions = () => { const getAllOptions = () => {
const configOptions = config.options || []; const configOptions = config.options || [];
return [...codeOptions, ...configOptions]; return [...codeOptions, ...categoryOptions, ...configOptions];
}; };
const options = getAllOptions(); const options = getAllOptions();
@ -204,12 +329,24 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기 // 모든 옵션 가져오기
const getAllOptions = () => { const getAllOptions = () => {
const configOptions = config.options || []; const configOptions = config.options || [];
return [...codeOptions, ...configOptions]; return [...codeOptions, ...categoryOptions, ...configOptions];
}; };
const allOptions = getAllOptions(); const allOptions = getAllOptions();
const placeholder = componentConfig.placeholder || "선택하세요"; const placeholder = componentConfig.placeholder || "선택하세요";
// 🔍 디버깅: 최종 옵션 확인
useEffect(() => {
if (webType === "category" && allOptions.length > 0) {
console.log("🔍 [SelectBasic] 최종 allOptions:", {
count: allOptions.length,
categoryOptionsCount: categoryOptions.length,
codeOptionsCount: codeOptions.length,
sampleOptions: allOptions.slice(0, 3),
});
}
}, [webType, allOptions.length, categoryOptions.length, codeOptions.length]);
// DOM props에서 React 전용 props 필터링 // DOM props에서 React 전용 props 필터링
const { const {
component: _component, component: _component,
@ -500,6 +637,93 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
} }
// select (기본 선택박스) // select (기본 선택박스)
// 다중선택 모드인 경우
if (isMultiple) {
return (
<div className="w-full">
<div
className={cn(
"box-border flex h-full min-h-[40px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
onClick={() => !isDesignMode && setIsOpen(true)}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);
return (
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
{opt?.label || val}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newVals = selectedValues.filter((v) => v !== val);
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
);
})}
{selectedValues.length === 0 && (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
const newVals = isSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="h-4 w-4"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
})
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
}
// 단일선택 모드
return ( return (
<div className="w-full"> <div className="w-full">
<div <div

View File

@ -21,7 +21,9 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onChange, onChange,
}) => { }) => {
const handleChange = (key: keyof SelectBasicConfig, value: any) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => {
onChange({ [key]: value }); // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
const newConfig = { ...config, [key]: value };
onChange(newConfig);
}; };
return ( return (
@ -67,6 +69,15 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("readonly", checked)} onCheckedChange={(checked) => handleChange("readonly", checked)}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="multiple"> </Label>
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => handleChange("multiple", checked)}
/>
</div>
</div> </div>
); );
}; };

View File

@ -73,6 +73,117 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태 // 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({}); const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
// 🆕 추가 입력 필드별 자동 채우기 테이블 컬럼 상태
const [autoFillTableColumns, setAutoFillTableColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
// 🆕 원본 테이블 컬럼 로드
useEffect(() => {
if (!config.sourceTable) {
setLoadedSourceTableColumns([]);
return;
}
const loadColumns = async () => {
try {
console.log("🔍 원본 테이블 컬럼 로드:", config.sourceTable);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(config.sourceTable);
if (response.success && response.data) {
const columns = response.data.columns || [];
setLoadedSourceTableColumns(columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
})));
console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length);
}
} catch (error) {
console.error("❌ 원본 테이블 컬럼 로드 오류:", error);
}
};
loadColumns();
}, [config.sourceTable]);
// 🆕 대상 테이블 컬럼 로드
useEffect(() => {
if (!config.targetTable) {
setLoadedTargetTableColumns([]);
return;
}
const loadColumns = async () => {
try {
console.log("🔍 대상 테이블 컬럼 로드:", config.targetTable);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(config.targetTable);
if (response.success && response.data) {
const columns = response.data.columns || [];
setLoadedTargetTableColumns(columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
})));
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
}
} catch (error) {
console.error("❌ 대상 테이블 컬럼 로드 오류:", error);
}
};
loadColumns();
}, [config.targetTable]);
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
useEffect(() => {
if (!localFields || localFields.length === 0) return;
localFields.forEach((field, index) => {
if (field.autoFillFromTable && !autoFillTableColumns[index]) {
console.log(`🔍 [초기화] 필드 ${index}의 기존 테이블 컬럼 로드:`, field.autoFillFromTable);
loadAutoFillTableColumns(field.autoFillFromTable, index);
}
});
}, []); // 초기 한 번만 실행
// 🆕 자동 채우기 테이블 선택 시 컬럼 로드
const loadAutoFillTableColumns = async (tableName: string, fieldIndex: number) => {
if (!tableName) {
setAutoFillTableColumns(prev => ({ ...prev, [fieldIndex]: [] }));
return;
}
try {
console.log(`🔍 [필드 ${fieldIndex}] 자동 채우기 테이블 컬럼 로드:`, tableName);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data) {
const columns = response.data.columns || [];
setAutoFillTableColumns(prev => ({
...prev,
[fieldIndex]: columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
}))
}));
console.log(`✅ [필드 ${fieldIndex}] 컬럼 로드 성공:`, columns.length);
} else {
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 실패:`, response);
}
} catch (error) {
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 오류:`, error);
}
};
// 🆕 소스 테이블 선택 시 컬럼 로드 // 🆕 소스 테이블 선택 시 컬럼 로드
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => { const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
try { try {
@ -180,7 +291,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행) }, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => { const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
onChange({ [key]: value }); // 🔧 기존 config와 병합하여 다른 속성 유지
onChange({ ...config, [key]: value });
}; };
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => { const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
@ -261,15 +373,19 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록 // 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
const availableColumns = useMemo(() => { const availableColumns = useMemo(() => {
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
const columns = loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns;
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName)); return columns.filter((col) => !usedColumns.has(col.columnName));
}, [sourceTableColumns, displayColumns, localFields]); }, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]);
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록 // 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
const availableTargetColumns = useMemo(() => { const availableTargetColumns = useMemo(() => {
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
const columns = loadedTargetTableColumns.length > 0 ? loadedTargetTableColumns : targetTableColumns;
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
return targetTableColumns.filter((col) => !usedColumns.has(col.columnName)); return columns.filter((col) => !usedColumns.has(col.columnName));
}, [targetTableColumns, displayColumns, localFields]); }, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]);
// 🆕 원본 테이블 필터링 // 🆕 원본 테이블 필터링
const filteredSourceTables = useMemo(() => { const filteredSourceTables = useMemo(() => {
@ -403,7 +519,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
role="combobox" role="combobox"
aria-expanded={sourceTableSelectOpen} aria-expanded={sourceTableSelectOpen}
className="h-8 w-full justify-between text-xs sm:text-sm" className="h-8 w-full justify-between text-xs sm:text-sm"
disabled={allTables.length === 0}
> >
{selectedSourceTableLabel} {selectedSourceTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
@ -677,15 +792,66 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[10px] sm:text-xs"> ()</Label> <Label className="text-[10px] sm:text-xs"> ()</Label>
{/* 테이블명 입력 */} {/* 테이블 선택 드롭다운 */}
<Input <Popover>
value={field.autoFillFromTable || ""} <PopoverTrigger asChild>
onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })} <Button
placeholder="비워두면 주 데이터 (예: item_price)" variant="outline"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" role="combobox"
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
>
{field.autoFillFromTable
? allTables.find(t => t.tableName === field.autoFillFromTable)?.displayName || field.autoFillFromTable
: "원본 테이블 (기본)"}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0 sm:w-[300px]">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
<CommandItem
value=""
onSelect={() => {
updateField(index, { autoFillFromTable: undefined, autoFillFrom: undefined });
setAutoFillTableColumns(prev => ({ ...prev, [index]: [] }));
}}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
!field.autoFillFromTable ? "opacity-100" : "opacity-0",
)}
/> />
({config.sourceTable || "미설정"})
</CommandItem>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(value) => {
updateField(index, { autoFillFromTable: value, autoFillFrom: undefined });
loadAutoFillTableColumns(value, index);
}}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
field.autoFillFromTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[9px] text-gray-500 sm:text-[10px]"> <p className="text-[9px] text-gray-500 sm:text-[10px]">
</p> </p>
{/* 필드 선택 */} {/* 필드 선택 */}
@ -696,16 +862,26 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
role="combobox" role="combobox"
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs" className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
> >
{field.autoFillFrom {(() => {
? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom if (!field.autoFillFrom) return "필드 선택 안 함";
: "필드 선택 안 함"}
// 선택된 테이블의 컬럼에서 찾기
const columns = field.autoFillFromTable
? (autoFillTableColumns[index] || [])
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
const found = columns.find(c => c.columnName === field.autoFillFrom);
return found?.columnLabel || field.autoFillFrom;
})()}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" /> <ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[180px] p-0 sm:w-[200px]"> <PopoverContent className="w-[180px] p-0 sm:w-[200px]">
<Command> <Command>
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" /> <CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty> <CommandEmpty className="text-[10px] sm:text-xs">
{field.autoFillFromTable ? "컬럼을 찾을 수 없습니다" : "원본 테이블을 먼저 선택하세요"}
</CommandEmpty>
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]"> <CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
<CommandItem <CommandItem
value="" value=""
@ -720,11 +896,17 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
/> />
</CommandItem> </CommandItem>
{sourceTableColumns.map((column) => ( {(() => {
// 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼
const columns = field.autoFillFromTable
? (autoFillTableColumns[index] || [])
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
return columns.map((column) => (
<CommandItem <CommandItem
key={column.columnName} key={column.columnName}
value={column.columnName} value={column.columnName}
onSelect={() => updateField(index, { autoFillFrom: column.columnName })} onSelect={(value) => updateField(index, { autoFillFrom: value })}
className="text-[10px] sm:text-xs" className="text-[10px] sm:text-xs"
> >
<Check <Check
@ -734,11 +916,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
)} )}
/> />
<div> <div>
<div className="font-medium">{column.columnLabel}</div> <div className="font-medium">{column.columnLabel || column.columnName}</div>
<div className="text-[9px] text-gray-500">{column.columnName}</div> {column.dataType && <div className="text-[8px] text-gray-500">{column.dataType}</div>}
</div> </div>
</CommandItem> </CommandItem>
))} ));
})()}
</CommandGroup> </CommandGroup>
</Command> </Command>
</PopoverContent> </PopoverContent>

View File

@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
{/* 요약 표시 설정 (LIST 모드에서만) */} {/* 요약 표시 설정 (LIST 모드에서만) */}
{config.rightPanel?.displayMode === "list" && ( {(config.rightPanel?.displayMode || "list") === "list" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>

View File

@ -148,7 +148,7 @@ export interface TableListComponentProps {
tableName?: string; tableName?: string;
onRefresh?: () => void; onRefresh?: () => void;
onClose?: () => void; onClose?: () => void;
screenId?: string; screenId?: number | string; // 화면 ID (필터 설정 저장용)
userId?: string; // 사용자 ID (컬럼 순서 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용)
onSelectedRowsChange?: ( onSelectedRowsChange?: (
selectedRows: any[], selectedRows: any[],
@ -183,6 +183,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey, refreshKey,
tableName, tableName,
userId, userId,
screenId, // 화면 ID 추출
}) => { }) => {
// ======================================== // ========================================
// 설정 및 스타일 // 설정 및 스타일
@ -1227,8 +1228,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
// 체크박스 컬럼 (나중에 위치 결정) // 체크박스 컬럼 (나중에 위치 결정)
// 기본값: enabled가 undefined면 true로 처리
let checkboxCol: ColumnConfig | null = null; let checkboxCol: ColumnConfig | null = null;
if (tableConfig.checkbox?.enabled) { if (tableConfig.checkbox?.enabled ?? true) {
checkboxCol = { checkboxCol = {
columnName: "__checkbox__", columnName: "__checkbox__",
displayName: "", displayName: "",
@ -1257,7 +1259,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 체크박스를 맨 앞 또는 맨 뒤에 추가 // 체크박스를 맨 앞 또는 맨 뒤에 추가
if (checkboxCol) { if (checkboxCol) {
if (tableConfig.checkbox.position === "right") { if (tableConfig.checkbox?.position === "right") {
cols = [...cols, checkboxCol]; cols = [...cols, checkboxCol];
} else { } else {
cols = [checkboxCol, ...cols]; cols = [checkboxCol, ...cols];
@ -1423,23 +1425,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
); );
} }
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원) // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
if (inputType === "category") { if (inputType === "category") {
if (!value) return ""; if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)]; const { Badge } = require("@/components/ui/badge");
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 // 다중 값 처리: 콤마로 구분된 값들을 분리
const displayLabel = categoryData?.label || String(value); const valueStr = String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 const values = valueStr.includes(",")
? valueStr.split(",").map(v => v.trim()).filter(v => v)
: [valueStr];
// 단일 값인 경우 (기존 로직)
if (values.length === 1) {
const categoryData = mapping?.[values[0]];
const displayLabel = categoryData?.label || values[0];
const displayColor = categoryData?.color || "#64748b";
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (displayColor === "none") { if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>; return <span className="text-sm">{displayLabel}</span>;
} }
const { Badge } = require("@/components/ui/badge");
return ( return (
<Badge <Badge
style={{ style={{
@ -1453,6 +1461,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
); );
} }
// 다중 값인 경우: 여러 배지 렌더링
return (
<div className="flex flex-wrap gap-1">
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
const displayColor = categoryData?.color || "#64748b";
if (displayColor === "none") {
return (
<span key={idx} className="text-sm">
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
);
}
return (
<Badge
key={idx}
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
})}
</div>
);
}
// 코드 타입: 코드 값 → 코드명 변환 // 코드 타입: 코드 값 → 코드명 변환
if (inputType === "code" && meta?.codeCategory && value) { if (inputType === "code" && meta?.codeCategory && value) {
try { try {
@ -1535,17 +1577,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// useEffect 훅 // useEffect 훅
// ======================================== // ========================================
// 필터 설정 localStorage 키 생성 // 필터 설정 localStorage 키 생성 (화면별로 독립적)
const filterSettingKey = useMemo(() => { const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null; if (!tableConfig.selectedTable) return null;
return `tableList_filterSettings_${tableConfig.selectedTable}`; return screenId
}, [tableConfig.selectedTable]); ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
// 그룹 설정 localStorage 키 생성 // 그룹 설정 localStorage 키 생성 (화면별로 독립적)
const groupSettingKey = useMemo(() => { const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null; if (!tableConfig.selectedTable) return null;
return `tableList_groupSettings_${tableConfig.selectedTable}`; return screenId
}, [tableConfig.selectedTable]); ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
// 저장된 필터 설정 불러오기 // 저장된 필터 설정 불러오기
useEffect(() => { useEffect(() => {

View File

@ -269,7 +269,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// }); // });
const parentValue = config[parentKey] as any; const parentValue = config[parentKey] as any;
// 전체 config와 병합하여 다른 속성 유지
const newConfig = { const newConfig = {
...config,
[parentKey]: { [parentKey]: {
...parentValue, ...parentValue,
[childKey]: value, [childKey]: value,
@ -754,6 +756,52 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 체크박스 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
</div>
<hr className="border-border" />
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxEnabled"
checked={config.checkbox?.enabled ?? true}
onCheckedChange={(checked) => handleNestedChange("checkbox", "enabled", checked)}
/>
<Label htmlFor="checkboxEnabled"> </Label>
</div>
{config.checkbox?.enabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxSelectAll"
checked={config.checkbox?.selectAll ?? true}
onCheckedChange={(checked) => handleNestedChange("checkbox", "selectAll", checked)}
/>
<Label htmlFor="checkboxSelectAll"> </Label>
</div>
<div className="space-y-1">
<Label htmlFor="checkboxPosition" className="text-xs">
</Label>
<select
id="checkboxPosition"
value={config.checkbox?.position || "left"}
onChange={(e) => handleNestedChange("checkbox", "position", e.target.value)}
className="w-full h-8 text-xs border rounded-md px-2"
>
<option value="left"></option>
<option value="right"></option>
</select>
</div>
</>
)}
</div>
</div>
{/* 가로 스크롤 및 컬럼 고정 */} {/* 가로 스크롤 및 컬럼 고정 */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>

View File

@ -12,6 +12,14 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options"; import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface PresetFilter {
id: string;
columnName: string;
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
}
interface TableSearchWidgetProps { interface TableSearchWidgetProps {
component: { component: {
id: string; id: string;
@ -25,6 +33,8 @@ interface TableSearchWidgetProps {
componentConfig?: { componentConfig?: {
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부 autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부 showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
filterMode?: "dynamic" | "preset"; // 필터 모드
presetFilters?: PresetFilter[]; // 고정 필터 목록
}; };
}; };
screenId?: number; // 화면 ID screenId?: number; // 화면 ID
@ -63,6 +73,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true; const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
const showTableSelector = component.componentConfig?.showTableSelector ?? true; const showTableSelector = component.componentConfig?.showTableSelector ?? true;
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
const presetFilters = component.componentConfig?.presetFilters ?? [];
// Map을 배열로 변환 // Map을 배열로 변환
const tableList = Array.from(registeredTables.values()); const tableList = Array.from(registeredTables.values());
@ -77,10 +89,27 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
// 현재 테이블의 저장된 필터 불러오기 // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => { useEffect(() => {
if (currentTable?.tableName) { if (!currentTable?.tableName) return;
const storageKey = `table_filters_${currentTable.tableName}`;
// 고정 모드: presetFilters를 activeFilters로 설정
if (filterMode === "preset") {
const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200,
}));
setActiveFilters(activeFiltersList);
return;
}
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
const storageKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}`
: `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(storageKey); const savedFilters = localStorage.getItem(storageKey);
if (savedFilters) { if (savedFilters) {
@ -110,8 +139,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
console.error("저장된 필터 불러오기 실패:", error); console.error("저장된 필터 불러오기 실패:", error);
} }
} }
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName]); }, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지) // select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => { useEffect(() => {
@ -362,7 +391,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
{/* 필터가 없을 때는 빈 공간 */} {/* 필터가 없을 때는 빈 공간 */}
{activeFilters.length === 0 && <div className="flex-1" />} {activeFilters.length === 0 && <div className="flex-1" />}
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */} {/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
{/* 데이터 건수 표시 */} {/* 데이터 건수 표시 */}
{currentTable?.dataCount !== undefined && ( {currentTable?.dataCount !== undefined && (
@ -371,6 +400,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div> </div>
)} )}
{/* 동적 모드일 때만 설정 버튼들 표시 */}
{filterMode === "dynamic" && (
<>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -403,6 +435,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> <Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
</>
)}
</div> </div>
{/* 패널들 */} {/* 패널들 */}
@ -411,6 +445,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
isOpen={filterOpen} isOpen={filterOpen}
onClose={() => setFilterOpen(false)} onClose={() => setFilterOpen(false)}
onFiltersApplied={(filters) => setActiveFilters(filters)} onFiltersApplied={(filters) => setActiveFilters(filters)}
screenId={screenId}
/> />
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} /> <GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
</div> </div>

View File

@ -3,27 +3,126 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, X } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TableSearchWidgetConfigPanelProps { interface TableSearchWidgetConfigPanelProps {
component: any; component?: any; // 레거시 지원
onUpdateProperty: (property: string, value: any) => void; config?: any; // 새 인터페이스
onUpdateProperty?: (property: string, value: any) => void; // 레거시 지원
onChange?: (newConfig: any) => void; // 새 인터페이스
tables?: any[]; // 화면의 테이블 정보
}
interface PresetFilter {
id: string;
columnName: string;
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
} }
export function TableSearchWidgetConfigPanel({ export function TableSearchWidgetConfigPanel({
component, component,
config,
onUpdateProperty, onUpdateProperty,
onChange,
tables = [],
}: TableSearchWidgetConfigPanelProps) { }: TableSearchWidgetConfigPanelProps) {
// 레거시와 새 인터페이스 모두 지원
const currentConfig = config || component?.componentConfig || {};
const updateConfig = onChange || ((key: string, value: any) => {
if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
});
// 첫 번째 테이블의 컬럼 목록 가져오기
const availableColumns = tables.length > 0 && tables[0].columns ? tables[0].columns : [];
// inputType에서 filterType 추출 헬퍼 함수
const getFilterTypeFromInputType = (inputType: string): "text" | "number" | "date" | "select" => {
if (inputType.includes("number") || inputType.includes("decimal") || inputType.includes("integer")) {
return "number";
}
if (inputType.includes("date") || inputType.includes("time")) {
return "date";
}
if (inputType.includes("select") || inputType.includes("dropdown") || inputType.includes("code") || inputType.includes("category")) {
return "select";
}
return "text";
};
const [localAutoSelect, setLocalAutoSelect] = useState( const [localAutoSelect, setLocalAutoSelect] = useState(
component.componentConfig?.autoSelectFirstTable ?? true currentConfig.autoSelectFirstTable ?? true
); );
const [localShowSelector, setLocalShowSelector] = useState( const [localShowSelector, setLocalShowSelector] = useState(
component.componentConfig?.showTableSelector ?? true currentConfig.showTableSelector ?? true
);
const [localFilterMode, setLocalFilterMode] = useState<"dynamic" | "preset">(
currentConfig.filterMode ?? "dynamic"
);
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
currentConfig.presetFilters ?? []
); );
useEffect(() => { useEffect(() => {
setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true); setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
setLocalShowSelector(component.componentConfig?.showTableSelector ?? true); setLocalShowSelector(currentConfig.showTableSelector ?? true);
}, [component.componentConfig]); setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
setLocalPresetFilters(currentConfig.presetFilters ?? []);
}, [currentConfig]);
// 설정 업데이트 헬퍼
const handleUpdate = (key: string, value: any) => {
if (onChange) {
// 새 인터페이스: 전체 config 업데이트
onChange({ ...currentConfig, [key]: value });
} else if (onUpdateProperty) {
// 레거시: 개별 속성 업데이트
onUpdateProperty(`componentConfig.${key}`, value);
}
};
// 필터 추가
const addFilter = () => {
const newFilter: PresetFilter = {
id: `filter_${Date.now()}`,
columnName: "",
columnLabel: "",
filterType: "text",
width: 200,
};
const updatedFilters = [...localPresetFilters, newFilter];
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
};
// 필터 삭제
const removeFilter = (id: string) => {
const updatedFilters = localPresetFilters.filter((f) => f.id !== id);
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
};
// 필터 업데이트
const updateFilter = (id: string, field: keyof PresetFilter, value: any) => {
const updatedFilters = localPresetFilters.map((f) =>
f.id === id ? { ...f, [field]: value } : f
);
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -41,7 +140,7 @@ export function TableSearchWidgetConfigPanel({
checked={localAutoSelect} checked={localAutoSelect}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setLocalAutoSelect(checked as boolean); setLocalAutoSelect(checked as boolean);
onUpdateProperty("componentConfig.autoSelectFirstTable", checked); handleUpdate("autoSelectFirstTable", checked);
}} }}
/> />
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer"> <Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
@ -56,7 +155,7 @@ export function TableSearchWidgetConfigPanel({
checked={localShowSelector} checked={localShowSelector}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setLocalShowSelector(checked as boolean); setLocalShowSelector(checked as boolean);
onUpdateProperty("componentConfig.showTableSelector", checked); handleUpdate("showTableSelector", checked);
}} }}
/> />
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer"> <Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
@ -64,12 +163,178 @@ export function TableSearchWidgetConfigPanel({
</Label> </Label>
</div> </div>
{/* 필터 모드 선택 */}
<div className="space-y-2 border-t pt-4">
<Label className="text-xs sm:text-sm font-medium"> </Label>
<RadioGroup
value={localFilterMode}
onValueChange={(value: "dynamic" | "preset") => {
setLocalFilterMode(value);
handleUpdate("filterMode", value);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dynamic" id="mode-dynamic" />
<Label htmlFor="mode-dynamic" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="preset" id="mode-preset" />
<Label htmlFor="mode-preset" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
</RadioGroup>
</div>
{/* 고정 모드일 때만 필터 설정 UI 표시 */}
{localFilterMode === "preset" && (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm font-medium"> </Label>
<Button
variant="outline"
size="sm"
onClick={addFilter}
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{localPresetFilters.length === 0 ? (
<div className="rounded-md bg-muted p-3 text-center text-xs text-muted-foreground">
. .
</div>
) : (
<div className="space-y-2">
{localPresetFilters.map((filter) => (
<div
key={filter.id}
className="rounded-md border bg-card p-3 space-y-2"
>
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
variant="ghost"
size="sm"
onClick={() => removeFilter(filter.id)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-[10px] sm:text-xs mb-1"> </Label>
{availableColumns.length > 0 ? (
<Select
value={filter.columnName}
onValueChange={(value) => {
// 선택된 컬럼 정보 가져오기
const selectedColumn = availableColumns.find(
(col: any) => col.columnName === value
);
// 컬럼명과 라벨 동시 업데이트
const updatedFilters = localPresetFilters.map((f) =>
f.id === filter.id
? {
...f,
columnName: value,
columnLabel: selectedColumn?.columnLabel || value,
filterType: getFilterTypeFromInputType(selectedColumn?.inputType || "text"),
}
: f
);
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span className="font-medium">{col.columnLabel}</span>
<span className="text-muted-foreground text-[10px]">
({col.columnName})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={filter.columnName}
onChange={(e) => updateFilter(filter.id, "columnName", e.target.value)}
placeholder="예: customer_name"
className="h-7 text-xs"
/>
)}
{filter.columnLabel && (
<p className="text-muted-foreground mt-1 text-[10px]">
: {filter.columnLabel}
</p>
)}
</div>
{/* 필터 타입 */}
<div>
<Label className="text-[10px] sm:text-xs mb-1"> </Label>
<Select
value={filter.filterType}
onValueChange={(value: "text" | "number" | "date" | "select") =>
updateFilter(filter.id, "filterType", value)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 너비 */}
<div>
<Label className="text-[10px] sm:text-xs mb-1"> (px)</Label>
<Input
type="number"
value={filter.width || 200}
onChange={(e) => updateFilter(filter.id, "width", parseInt(e.target.value))}
placeholder="200"
className="h-7 text-xs"
min={100}
max={500}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="rounded-md bg-muted p-3 text-xs"> <div className="rounded-md bg-muted p-3 text-xs">
<p className="font-medium mb-1">:</p> <p className="font-medium mb-1">:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> <ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li> , , </li> <li> , , </li>
<li> </li> <li> </li>
<li> </li> {localFilterMode === "dynamic" ? (
<li> </li>
) : (
<li> </li>
)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -2,8 +2,8 @@ import React from "react";
import { TableSearchWidget } from "./TableSearchWidget"; import { TableSearchWidget } from "./TableSearchWidget";
export class TableSearchWidgetRenderer { export class TableSearchWidgetRenderer {
static render(component: any) { static render(component: any, props?: any) {
return <TableSearchWidget component={component} />; return <TableSearchWidget component={component} screenId={props?.screenId} />;
} }
} }

View File

@ -72,6 +72,11 @@ export interface PlacedObject {
// 편집 제한 // 편집 제한
locked?: boolean; // true면 이동/크기조절 불가 locked?: boolean; // true면 이동/크기조절 불가
visible?: boolean; visible?: boolean;
// 동적 계층 구조
hierarchyLevel?: number; // 1, 2, 3...
parentKey?: string; // 부모 객체의 외부 DB 키
externalKey?: string; // 자신의 외부 DB 키
} }
// 레이아웃 // 레이아웃
@ -82,6 +87,7 @@ export interface DigitalTwinLayout {
warehouseKey: string; // WAREKEY (예: DY99) warehouseKey: string; // WAREKEY (예: DY99)
layoutName: string; layoutName: string;
description?: string; description?: string;
hierarchyConfig?: any; // JSON 설정
isActive: boolean; isActive: boolean;
createdBy?: number; createdBy?: number;
updatedBy?: number; updatedBy?: number;