Compare commits

...

309 Commits

Author SHA1 Message Date
hjlee 147d187901 Merge pull request 'lhj' (#372) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/372
2026-01-19 17:26:05 +09:00
leeheejin d09a6977f7 검색필터 업그레이드 2026-01-19 17:25:12 +09:00
leeheejin faf4100056 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-19 13:17:10 +09:00
kjs 410b4a7b14 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-19 12:07:30 +09:00
kjs e4667cce5f refactor: 테이블 관리 서비스에서 쿼리 및 로깅 개선
- 다중 값 배열 검색 시 조건 처리 로직 개선
- 쿼리에서 main. 접두사 추가하여 명확한 테이블 참조 보장
- 불필요한 공백 제거 및 코드 가독성 향상
- 엔티티 관계 감지 로깅 개선으로 디버깅 용이성 증가
- 새로운 수주관리 및 거래처 테이블 추가로 멀티테넌시 지원 강화
2026-01-19 12:07:29 +09:00
leeheejin c282d5c611 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-19 10:14:48 +09:00
leeheejin d4afc06f4a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-19 10:14:38 +09:00
leeheejin f2ab4f11bd 진짜 해결했음 진짜진짜로 2026-01-19 10:14:20 +09:00
hjlee 514d852fa6 Merge pull request '배포 다시..' (#371) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/371
2026-01-19 09:50:47 +09:00
leeheejin 8603fddbcb 배포 다시.. 2026-01-19 09:50:25 +09:00
hjlee 58adc0a100 Merge pull request 'lhj' (#370) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/370
2026-01-16 18:15:42 +09:00
leeheejin 0382c94d73 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 18:15:21 +09:00
leeheejin 49f67451eb 피벗수정오늘최종 2026-01-16 18:14:55 +09:00
DDD1542 e3852aca5d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-16 17:41:21 +09:00
DDD1542 df8065503d feat: 메뉴 삭제 및 동기화 기능 개선
- 메뉴 삭제 시 하위 메뉴를 재귀적으로 수집하여 관련 데이터 정리 기능 추가
- 메뉴 삭제 성공 시 삭제된 메뉴와 하위 메뉴 수를 포함한 응답 메시지 개선
- 메뉴 복제 시 항상 활성화 상태로 설정
- 화면-메뉴 동기화 진행 상태를 사용자에게 알리기 위한 프로그레스 메시지 추가
- 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵하는 로직 추가
- 동기화 진행 중 오버레이 UI 개선
2026-01-16 17:41:19 +09:00
hjlee 0a85146564 Merge pull request 'lhj' (#369) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/369
2026-01-16 17:40:11 +09:00
leeheejin ad3b853d04 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 17:39:55 +09:00
leeheejin 2a3cc7ba00 배포다시 되게 고쳐놓음 2026-01-16 17:39:35 +09:00
hjlee ee273c5103 Merge pull request 'lhj' (#368) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/368
2026-01-16 16:51:03 +09:00
leeheejin 50a25cb9de Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 16:50:34 +09:00
leeheejin d1631d15ff 안닫히게 수정 2 2026-01-16 16:49:59 +09:00
hjlee a020985630 Merge pull request 'lhj' (#367) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/367
2026-01-16 16:25:12 +09:00
leeheejin 351ecbb35d 배포에서 오류 안나게 수정 2026-01-16 16:24:43 +09:00
leeheejin d32e933c03 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 16:12:09 +09:00
leeheejin 4497985104 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-16 16:11:34 +09:00
kjs b97b0cc7d7 refactor: 화면 그룹 관련 API에서 AuthenticatedRequest 타입 사용
- 화면 그룹 목록 조회, 상세 조회, 생성, 수정, 삭제 API에서 Request 타입을 AuthenticatedRequest로 변경하여 사용자 인증 정보를 명확히 처리
- companyCode를 req.user?.companyCode || "*"로 설정하여 기본값 처리 개선
- 관련 API의 일관성 있는 타입 사용으로 코드 가독성 및 유지보수성 향상
2026-01-16 15:52:35 +09:00
hjlee 160ad87395 Merge pull request 'lhj' (#364) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/364
2026-01-16 15:18:32 +09:00
leeheejin 4972f26cee Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 15:18:09 +09:00
leeheejin 02eee979ea 고치기 완료 2026-01-16 15:17:49 +09:00
DDD1542 08de1372c5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-16 14:48:21 +09:00
DDD1542 ab52c49492 feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
- 화면 관리 시스템의 복제, 삭제, 수정 및 테이블 설정 기능을 전면 개선
- 그룹 삭제 시 하위 그룹과의 연관성 정리 및 로딩 프로그레스 바 추가
- 화면 수정 기능 추가: 이름, 그룹, 역할, 정렬 순서 변경
- 테이블 설정 모달에 관련 기능 추가 및 데이터 일관성 유지
- 메뉴-화면 그룹 동기화 API 추가 및 관련 상태 관리 기능 구현
- 검색어 필터링 로직 개선: 다중 키워드 지원
- 관련 파일 및 진행 상태 업데이트
2026-01-16 14:48:15 +09:00
leeheejin 8a865ac1f4 이상한 부분 수정 피벗그리드 2026-01-16 14:29:19 +09:00
hjlee 0a89cc2fb0 Merge pull request '피벗' (#363) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/363
2026-01-16 14:03:31 +09:00
leeheejin ab3a493abb 피벗 2026-01-16 14:03:07 +09:00
hjlee ac0f461832 Merge pull request 'lhj' (#362) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/362
2026-01-16 10:19:35 +09:00
leeheejin c2256de8ec Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-16 10:19:05 +09:00
leeheejin 484c98da9e 피벗그리드 고침 2026-01-16 10:18:11 +09:00
DDD1542 b2dc06d0f2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-15 17:36:41 +09:00
DDD1542 efa95af4b9 refactor: 코드 정리 및 불필요한 주석 제거
- EntityJoinController에서 중복 제거 설정 관련 주석 및 코드 삭제
- screenGroupController와 tableManagementController에서 AuthenticatedRequest 타입을 일반 Request로 변경
- 불필요한 로그 및 주석 제거로 코드 가독성 향상
- tableManagementController에서 에러 메시지 개선
2026-01-15 17:36:38 +09:00
SeongHyun Kim e8bdcbb95c fix: 발주/입고관리 그룹 편집 시 단건만 저장되던 문제 수정
EditModal.tsx: conditional-container 존재 시 onSave 미전달 로직 수정
ModalRepeaterTableComponent.tsx: groupedData prop 우선 사용하도록 변경
2026-01-15 17:05:01 +09:00
SeongHyun Kim 60ae073606 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-15 16:56:44 +09:00
SeongHyun Kim a36802ab10 fix: RepeaterFieldGroup 저장 경로에 채번 규칙 할당 로직 추가 2026-01-15 16:54:02 +09:00
hjlee 98c489ee22 Merge pull request '코드로 보이던 문제 최소한의 코드만 고침' (#360) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/360
2026-01-15 16:53:40 +09:00
leeheejin c77c6290d3 코드로 보이던 문제 최소한의 코드만 고침 2026-01-15 16:53:27 +09:00
gbpark 9dc549be09 Merge pull request 'feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)' (#359) from feature/v2-renewal into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/359
2026-01-15 14:59:21 +09:00
gbpark 40a226ca30 Merge branch 'main' into feature/v2-renewal 2026-01-15 14:59:12 +09:00
DDD1542 5d89b69451 feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)
- 단일 화면 복제 및 그룹 전체 복제 기능 추가
- 정렬 순서 유지 및 일괄 이름 변경 기능 구현
- 삭제 기능 개선: 단일 화면 삭제 및 그룹 삭제 시 옵션 추가
- 회사 코드 지원 기능 추가: 복제된 그룹/화면에 선택한 회사 코드 적용
- 관련 파일 및 진행 상태 업데이트
2026-01-15 14:58:12 +09:00
SeongHyun Kim 7fd3364aef Merge branch 'ksh' 2026-01-15 13:27:01 +09:00
hjlee 2326c3548b Merge pull request 'lhj' (#358) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/358
2026-01-15 12:08:46 +09:00
leeheejin 220ce57be1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-15 12:07:51 +09:00
leeheejin 0ac83b1551 분할패널에서 수정해도 리스트에서 삭제가 안되던 문제 코드 해결 2026-01-15 12:07:26 +09:00
kjs 3f474ecddd 오류수정 2026-01-15 11:00:30 +09:00
kjs ddf5ed4006 빌드에러 2026-01-15 10:52:54 +09:00
kjs c4ee084a1d 빌드에러 수정 2026-01-15 10:47:58 +09:00
SeongHyun Kim 2e02ace388 feat(repeater): 하위 데이터 조회 및 조건부 입력 기능 구현, 테이블 선택 데이터 동기화 개선
Repeater 컴포넌트에 하위 데이터 조회 기능 추가 (재고/단가 조회)
조건부 입력 활성화 및 최대값 제한 기능 구현
필드 정의 순서 변경 기능 추가 (드래그앤드롭, 화살표 버튼)
TableListComponent의 DataProvider 클로저 문제 해결
ButtonPrimaryComponent에 modalDataStore fallback 로직 추가
2026-01-15 10:35:34 +09:00
kjs 435eb90763 Merge pull request 'feat/multilang' (#357) from feat/multilang into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/357
2026-01-14 18:28:20 +09:00
kjs 98870b3348 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 17:14:42 +09:00
kjs b7b750d134 번역현황 표시 2026-01-14 17:14:27 +09:00
kjs ac334db0b1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 16:33:38 +09:00
kjs 16c9c71a23 다국어 화면에서 수정기능 구현 2026-01-14 16:33:22 +09:00
DDD1542 059ea6b30a feat: 테이블 설정 모달의 컬럼 저장 로직 개선
- 변경된 컬럼들만 저장하도록 로직을 개선하여 불필요한 API 호출을 줄임
- 기존 컬럼 정보와 편집된 값을 병합하여 최종 설정을 구성
- 엔티티 및 코드 타입에 대한 세부 설정 처리 로직 추가
- 저장 성공 시 사용자에게 피드백 제공 및 편집 상태 초기화 기능 구현
2026-01-14 16:09:00 +09:00
kjs 14f8714ea1 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 15:39:04 +09:00
kjs a27cb85007 코드 정리 및 최적화: InteractiveScreenViewer 컴포넌트의 불필요한 공백 제거 및 Select 컴포넌트의 props 정리 2026-01-14 15:38:52 +09:00
kjs b5d2195cd5 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-14 15:38:41 +09:00
DDD1542 0a3d42f3ad feat: 테이블 타입 관리 모달 추가 및 기존 테이블 생성 모달 제거
- 테이블 설정 모달에 테이블 타입 관리 모달을 추가하여 데이터베이스 테이블과 컬럼의 타입을 관리할 수 있도록 개선
- 기존의 테이블 생성 모달을 제거하고, 버튼 텍스트를 "새 테이블 생성"에서 "테이블 타입 관리"로 변경
- 테이블 타입 관리 모달의 헤더 및 설명 추가, 모달 닫기 버튼 기능 구현
- 테이블 데이터 새로고침 기능을 추가하여 모달 닫기 시 데이터가 최신 상태로 유지되도록 개선
2026-01-14 15:37:29 +09:00
kjs b5c2e85496 화면 다국어 처리 2026-01-14 15:33:57 +09:00
DDD1542 f321aaf7aa feat: 화면 디자이너 모달 및 제어 관리 탭 기능 추가
- 화면 설정 모달에 "제어 관리" 탭 추가하여 버튼 제어 설정을 간편하게 관리
- 버튼 액션 설정 기능 구현: 버튼 목록 표시 및 각 버튼의 액션 타입 수정 가능
- 화면 디자이너 모달 통합: 전체화면 Dialog 내부에 ScreenDesigner 임베드
- URL 쿼리 파라미터로 화면 디자이너 자동 열기 기능 추가
- 화면 캔버스 크기 자동 조절 기능 구현: 최소 크기 보장 및 여유 마진 추가
- 필드 추가/제거 기능 개선: 기존 그리드 컬럼 변경 로직과 통합하여 사용자 경험 향상
2026-01-14 14:35:27 +09:00
kjs 26bb93ab6e 다국어 생성후 매핑 자동저장 2026-01-14 13:26:41 +09:00
kjs f9575d7b5f 다국어 버튼 자동매핑 2026-01-14 13:08:44 +09:00
kjs c26b346054 다국어설정 모달생성 2026-01-14 11:51:24 +09:00
kjs 24315215de 다국어 키 자동생성 로직 2026-01-14 11:05:57 +09:00
SeongHyun Kim ca73685bc2 Merge remote-tracking branch 'origin/main' into ksh 2026-01-14 10:22:58 +09:00
kjs 61a7f585b4 다국어 자동생성 2026-01-14 10:20:27 +09:00
SeongHyun Kim cf97db7fbf feat(universal-form-modal): Select 필드 직접 입력(Combobox) 모드 추가
SelectOptionConfig에 allowCustomInput 옵션 추가
FieldDetailSettingsModal에 "직접 입력 허용" Switch UI 추가
CascadingSelectField에 Combobox 모드 구현 (Popover+Command)
SelectField에 Combobox 모드 구현
목록 선택과 직접 입력 동시 지원
2026-01-13 18:44:59 +09:00
kjs 18b5161398 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feat/multilang 2026-01-13 18:32:04 +09:00
kjs b576837f18 다국어 관리 시스템 개선: 카테고리 및 키 자동 생성 기능 추가 2026-01-13 18:28:11 +09:00
SeongHyun Kim ef27e0e38f feat(universal-form-modal): 연쇄 드롭다운(Cascading Dropdown) 기능 구현
- SelectOptionConfig에 cascading 타입 및 설정 객체 추가
- FieldDetailSettingsModal에 연쇄 드롭다운 설정 UI 구현
  - 부모 필드 선택 (섹션별 그룹핑 콤보박스)
  - 관계 코드 선택 시 상세 설정 자동 채움
  - 소스 테이블, 부모 키 컬럼, 값/라벨 컬럼 설정
- UniversalFormModalComponent에 자식 필드 초기화 로직 추가
- selectOptions.cascading 방식 CascadingSelectField 렌더링 지원
2026-01-13 18:26:41 +09:00
SeongHyun Kim d7d7dabe84 fix: Select 옵션의 참조 테이블 컬럼 자동 로드 추가
- FieldDetailSettingsModal에서 모달 열릴 때 Select 옵션의 참조 테이블 컬럼을 자동으로 로드하도록 useEffect 추가
- field.selectOptions.tableName이 설정되어 있고 해당 테이블의 컬럼이 로드되지 않은 경우 onLoadTableColumns 호출
- 기존 linkedFieldGroup의 sourceTable 로드 로직과 동일한 패턴 적용
2026-01-13 17:05:16 +09:00
hjlee d22fd078be Merge pull request 'lhj' (#356) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/356
2026-01-13 15:43:38 +09:00
leeheejin 28fe908704 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 15:43:24 +09:00
leeheejin 1b5ae5fe1c 일단 오류 수정 2026-01-13 15:43:04 +09:00
DDD1542 905a9f62c3 feat: 프리뷰 모드에서 회사 코드 오버라이드 기능 추가
- 최고 관리자만 다른 회사 코드로 오버라이드 가능하도록 로직 개선
- entityJoinController 및 tableManagementController에서 회사 코드 오버라이드 처리 추가
- 관련 API 호출 시 오버라이드된 회사 코드 적용
- 프리뷰 모드 감지 및 UI 개선을 위한 코드 추가
2026-01-13 13:28:50 +09:00
kjs 989b7e53a7 Merge pull request 'feature/screen-management' (#355) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/355
2026-01-13 10:25:20 +09:00
kjs 20e144af36 Merge branch 'main' into feature/screen-management 2026-01-13 10:25:08 +09:00
hjlee e2a22bb853 Merge pull request 'lhj' (#354) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/354
2026-01-13 10:04:41 +09:00
leeheejin 0deb466557 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 10:04:19 +09:00
leeheejin f64279d084 카테고리 연속입력 2026-01-13 10:04:05 +09:00
SeongHyun Kim c74e97d66e chore: update react-is to version 18.3.1 and downgrade recharts to version 3.2.1 in package.json and package-lock.json 2026-01-13 09:35:01 +09:00
kjs 0beb8b20a3 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-13 09:30:59 +09:00
hjlee 054da65a26 Merge pull request 'lhj' (#353) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/353
2026-01-13 09:30:27 +09:00
kjs 75e6c9eb1a 엑셀업로드 제어로직 개선 2026-01-13 09:30:19 +09:00
leeheejin 0f2d0bb053 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-13 09:30:11 +09:00
leeheejin 306de370f1 탭 값 필터링 2026-01-13 09:29:58 +09:00
kjs b6fefe2ebd 엑셀 업로드 로직 개선 2026-01-12 17:43:34 +09:00
SeongHyun Kim f799402564 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ksh 2026-01-12 17:28:11 +09:00
leeheejin 033f5eaf7e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 17:27:28 +09:00
leeheejin d094b58ebf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-12 17:27:18 +09:00
hjlee 3fa57ad2ae Merge pull request 'lhj' (#352) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/352
2026-01-12 17:26:48 +09:00
SeongHyun Kim 821955cfac fix: 렉 구조 등록 카테고리 데이터 저장 방식 통일 및 중복 체크 수정
RackStructureComponent: context에 카테고리 코드 필드(floorCode, zoneCode 등) 추가
기존 데이터 조회 시 warehouse_id -> warehouse_code 컬럼명 수정
미리보기 생성 시 카테고리 코드로 저장하도록 변경
미리보기 테이블에서 카테고리 코드를 라벨로 변환하여 표시
buttonActions: 중복 체크 API 호출 시 filters -> search 파라미터로 변경
types: RackStructureContext 인터페이스에 카테고리 코드 필드 추가
2026-01-12 17:25:52 +09:00
leeheejin b358a46c33 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 17:25:29 +09:00
hjjeong b2add92abf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-12 17:25:22 +09:00
leeheejin c2836a0209 일단 퇴사일 입력하면 필터링 적용되게 수정해놓음 2026-01-12 17:25:12 +09:00
hjjeong 472fc8633c 상단마스터정보+하단품목정보 수정시 오류 수정 2026-01-12 17:24:25 +09:00
kjs 4801ee5ca4 Merge pull request 'feature/screen-management' (#351) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/351
2026-01-12 17:20:23 +09:00
kjs 87189c792e 카테고리값 자동감지 2026-01-12 16:08:02 +09:00
kjs 9cc5bbbf05 엑셀 업로드 문제 수정 2026-01-12 13:53:57 +09:00
kjs 5f991db9c4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-12 10:33:01 +09:00
kjs 9e7253a293 카테고리 라벨 보이지 않는 문제 수정 2026-01-12 10:32:41 +09:00
leeheejin 31e87e0bca Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-12 09:25:13 +09:00
DDD1542 0773989c74 feat: 데이터 흐름 조회 기능 개선 및 프리뷰 모드 추가
- 데이터 흐름 조회 API에 source_screen_id 파라미터 추가하여 특정 화면에서 시작하는 데이터 흐름만 조회 가능
- 화면 관리 페이지에서 선택된 그룹에 company_code 필드 추가하여 회사 코드 정보 포함
- 프리뷰 모드에서 URL 쿼리로 company_code를 받아와 데이터 조회 시 우선 사용하도록 로직 개선
- 화면 관계 흐름 및 서브 테이블 정보에서 company_code를 활용하여 필터링 및 시각화 개선
2026-01-09 18:26:37 +09:00
kjs 6732e7d969 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 18:22:51 +09:00
kjs 35f83c1937 로그 정리 2026-01-09 18:22:50 +09:00
kjs 8aa6008351 Merge pull request 'feature/screen-management' (#350) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/350
2026-01-09 17:57:08 +09:00
kjs 47b61a9a35 Merge branch 'main' into feature/screen-management 2026-01-09 17:57:01 +09:00
kjs d22c2ec96e Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 17:56:49 +09:00
kjs 3677c77da0 단일테이블 엑셀 업로드 채번 구현 2026-01-09 17:56:48 +09:00
SeongHyun Kim c11e80a43c Merge remote-tracking branch 'origin/main' into ksh 2026-01-09 17:46:44 +09:00
SeongHyun Kim f8fb7d687e fix: SelectedItemsDetailInput 수정 모드에서 다중 레코드 로드 오류 수정
DynamicComponentRenderer에 selected-items-detail-input groupedData 전달 추가
SelectedItemsDetailInput에서 groupedData 우선 사용하도록 변경
ScreenModal editData 배열 처리 시 formData/selectedData 분리 저장
TextInput 수정 모드에서 채번 규칙 스킵 로직 추가
2026-01-09 17:42:33 +09:00
DDD1542 a6569909a2 feat: 저장 테이블 제외 조건 추가 및 포커싱 개선
- 저장 테이블 쿼리에 table-list와 체크박스가 활성화된 화면, openModalWithData 버튼이 있는 화면을 제외하는 조건 추가
- 화면 그룹 클릭 시 새 그룹 진입 시 포커싱 없이 시작하도록 로직 개선
- 관련 문서에 제외 조건 및 SQL 예시 추가
2026-01-09 17:03:00 +09:00
leeheejin 5c9dda6826 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:58:26 +09:00
leeheejin bcf512d2b5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-09 15:58:16 +09:00
kjs 4d41cb40b6 Merge pull request '빌드에러 수정' (#349) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/349
2026-01-09 15:57:15 +09:00
kjs bf74dd0f92 Merge branch 'main' into feature/screen-management 2026-01-09 15:57:09 +09:00
kjs 85ae1c1521 빌드에러 수정 2026-01-09 15:56:56 +09:00
leeheejin 38455325dd Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:48:39 +09:00
kjs f493f8ac80 Merge pull request 'feature/screen-management' (#348) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/348
2026-01-09 15:46:41 +09:00
kjs 7fc341bca8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 15:46:24 +09:00
kjs ba2a281245 엑셀 업로드 제어로직 설정 가능하도록 수정 2026-01-09 15:46:09 +09:00
kjs aa0698556e 엑셀 업로드,다운로드 기능 개선 2026-01-09 15:32:02 +09:00
leeheejin c76123a927 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 15:21:12 +09:00
leeheejin ba20a2bf42 피벗 테스트만 하면 됨 기능은 완료 2026-01-09 15:11:30 +09:00
SeongHyun Kim 23c9604672 Merge origin/main into ksh - resolve conflicts 2026-01-09 14:55:16 +09:00
SeongHyun Kim 64c6942de3 feat(split-panel-layout): 멀티 테이블 탭 기능 추가
AdditionalTabConfig 타입 정의 (우측 패널과 동일한 설정 구조)
설정 패널에 추가 탭 설정 UI 구현 (테이블, 조인키, 컬럼, 필터, 중복제거, 버튼)
컴포넌트에 탭 상태 관리 및 데이터 로딩 로직 추가
탭 바 UI 및 탭별 컨텐츠 렌더링 구현
기존 기능 유지 (탭 미사용 시 동일 동작)
2026-01-09 14:52:32 +09:00
leeheejin f07448ac17 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 14:41:57 +09:00
leeheejin d49883d25f 피벗 추가 2026-01-09 14:41:27 +09:00
hjjeong 217e390fe9 Merge pull request 'fix/split-panel-edit-group-records' (#347) from fix/split-panel-edit-group-records into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/347
2026-01-09 14:34:16 +09:00
hjjeong 363ef44586 Merge remote-tracking branch 'origin/main' into fix/split-panel-edit-group-records 2026-01-09 14:33:02 +09:00
hjjeong 48aa004a7f fix: SplitPanelLayout 수정 버튼 클릭 시 그룹 레코드 조회 개선
- 수정 버튼 클릭 시 groupByColumns 기준으로 모든 관련 레코드 조회
- search 대신 dataFilter(equals) 사용하여 정확 매칭 조회
- deduplication 명시적 비활성화로 모든 레코드 반환
- supplier_mng, customer_mng 등 회사별 데이터 테이블 DB 조인 강제 (캐시 미사용)
- entityJoinController에 deduplication 파라미터 처리 추가
- ScreenModal에서 배열 형태 editData 처리 지원
2026-01-09 14:11:51 +09:00
kjs ee3a648917 삭제버튼 제어 동작하지 않던 오류 수정 2026-01-09 13:43:14 +09:00
leeheejin 819a281df4 fix: 피벗그리드 컴포넌트 레지스트리 등록 누락 수정 2026-01-09 13:34:55 +09:00
leeheejin dd1d3bb44d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 13:28:40 +09:00
leeheejin 52e6824e76 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj 2026-01-09 13:28:26 +09:00
kjs 80cf20e142 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 12:17:27 +09:00
hjlee abddb67a30 Merge pull request 'lhj' (#346) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/346
2026-01-09 11:52:42 +09:00
leeheejin a0a9253d2c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-09 11:51:48 +09:00
leeheejin 222a00b8a9 그리드랑 노드에서 delete 가 where 입력했는데도 저장이 안되던 오류 해결 2026-01-09 11:51:35 +09:00
leeheejin e8516d9d6b feat: DELETE 노드 WHERE 조건에 소스 필드 선택 기능 추가
- 소스 필드 목록을 연결된 입력 노드에서 자동으로 로딩
- WHERE 조건에 소스 필드 선택 Combobox 추가
- 정적 값과 소스 필드 중 선택 가능
- 조건 변경 시 자동 저장 기능 추가
2026-01-09 11:44:14 +09:00
kjs 150a40e2a8 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-09 11:21:17 +09:00
kjs cea3aa53ae 엑셀 업로드 분할테이블 지원 2026-01-09 11:21:16 +09:00
DDD1542 af4072cef1 feat: 저장 테이블 정보 및 애니메이션 기능 추가
- 화면 서브 테이블에서 저장 테이블 정보를 추출하는 쿼리 추가
- 저장 테이블 정보 구조를 TableNodeData 인터페이스에 통합
- 저장 테이블의 시각적 표현을 위한 애니메이션 효과 추가
- 필터링 및 참조 관계 뱃지 레이아웃 개선
- 테이블 높이 부드러운 애니메이션 및 스크롤 기능 구현
2026-01-09 11:19:30 +09:00
kjs a50222e7d5 Merge pull request '이미지 문제 수정' (#345) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/345
2026-01-09 09:55:48 +09:00
kjs 69711f4e4b Merge branch 'main' into feature/screen-management 2026-01-09 09:55:39 +09:00
kjs 2eccd1982c 이미지 문제 수정 2026-01-09 09:55:24 +09:00
hjjeong 0baffafac1 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-08 18:01:07 +09:00
hjjeong 910d070055 fix: SelectedItemsDetailInput 저장 시 NULL 레코드 생성 버그 수정 2026-01-08 17:59:27 +09:00
hjjeong 8f4c95d20d fix: SelectedItemsDetailInput 저장 시 NULL 레코드 생성 버그 수정
- beforeFormSave 이벤트에 skipDefaultSave 플래그 추가
- UPSERT 처리 시 기본 저장 로직 건너뛰기
- 빈 엔트리 필터링으로 불필요한 레코드 생성 방지
2026-01-08 17:40:50 +09:00
kjs 65e1c1a995 Merge pull request '분할패널 수정버튼 동작하게 수정' (#344) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/344
2026-01-08 17:37:15 +09:00
kjs d2c15d519d 분할패널 수정버튼 동작하게 수정 2026-01-08 17:36:40 +09:00
hjlee 583c6c8c79 Merge pull request 'lhj' (#343) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/343
2026-01-08 17:15:03 +09:00
leeheejin a52ab0b206 refactor: pivot-grid 컴포넌트 개선 2026-01-08 17:11:46 +09:00
leeheejin 551e893f15 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-08 17:10:04 +09:00
leeheejin 85f8637ce0 fix: 채번 규칙 할당 로직 개선 - 복사 시 품목코드 자동생성 수정 2026-01-08 17:06:28 +09:00
leeheejin b85b3cd578 그리드? 일단 추가랑 복사기능 되게 했음 2026-01-08 17:05:27 +09:00
DDD1542 b8c8b31033 feat: 서브 테이블 조인 및 필터 관계 정보 개선
- rightPanel.columns에서 참조하는 외부 테이블 및 조인 컬럼 정보를 수집하는 로직 추가
- 서브 테이블의 조인 컬럼 참조 정보를 포함하여 시각화 개선
- 필터 관계를 선 없이 뱃지로 표시하여 겹침 방지 및 시각적 표현 개선
- TableNodeData 인터페이스에 조인 컬럼 및 참조 정보 필드 추가
- 화면 관계 흐름에서 조인 컬럼 및 필터 관계 정보 표시 기능 개선
2026-01-08 16:20:51 +09:00
kjs 0f57309d74 Merge pull request '분할패널 설정변경' (#342) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/342
2026-01-08 15:56:30 +09:00
kjs 4dfa82d3dd 분할패널 설정변경 2026-01-08 15:56:06 +09:00
SeongHyun Kim 34e48993e4 feat(SplitPanelLayout2): 개별 수정 버튼에 모달 화면 선택 UI 추가
- 좌측/우측 패널의 개별 수정 버튼 설정에 수정 모달 화면 선택 Combobox 추가
- 수정 버튼 ON 시 모달 화면 선택 UI 표시
- editModalScreenId 설정값 저장 및 사용
- 기존 폴백 로직 유지 (editModalScreenId 없으면 addModalScreenId 사용)
2026-01-08 15:37:22 +09:00
kjs 9821afe9cd Merge pull request 'feature/screen-management' (#341) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/341
2026-01-08 14:49:45 +09:00
kjs 38599a1bef Merge branch 'main' into feature/screen-management 2026-01-08 14:49:39 +09:00
kjs 11e25694b9 엔티티타입 입력 셀렉트박스 다중선택 기능 2026-01-08 14:49:24 +09:00
DDD1542 8928d851ca feat: 서브 테이블 정보 및 관계 시각화 개선
- 화면 서브 테이블에서 valueField, parentFieldId, cascadingParentField, controlField 정보를 추출하는 쿼리 추가
- 서브 테이블의 관계 유형을 추론하기 위한 추가 정보 필드(originalRelationType, foreignKey, leftColumn) 포함
- 필터링에 사용되는 FK 컬럼을 TableNodeData 인터페이스에 추가하여 시각화 개선
- 관계 유형별 색상 정의 및 시각적 관계 유형 추론 함수 추가
- 화면 관계 흐름에서 서브 테이블 연결선 및 필터링 참조 정보 표시 기능 개선
2026-01-08 14:24:33 +09:00
kjs 3f81c449ad 코드병합기능 개선 2026-01-08 14:24:07 +09:00
hjjeong 00006bf2e2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-08 14:15:53 +09:00
hjjeong 3e9bf29bcf fix: SplitPanelLayout 그룹 삭제 시 groupByColumns 기준 삭제 및 멀티테넌시 보호 추가(영업관리_거래처별 품목 등록 등에서,,) 2026-01-08 14:13:19 +09:00
SeongHyun Kim 34ac1b0c42 Merge remote-tracking branch 'origin/main' into ksh 2026-01-08 14:06:07 +09:00
leeheejin df94d73662 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-08 12:34:39 +09:00
leeheejin dc449f6c69 글씨크기 조정 2026-01-08 12:34:19 +09:00
kjs dcf3a63d9b Merge pull request 'feature/screen-management' (#340) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/340
2026-01-08 12:33:15 +09:00
kjs a3c83c834e 드래그핸들 기능 수정 2026-01-08 12:32:50 +09:00
kjs 980c929d83 리도,언도기능 2026-01-08 12:28:48 +09:00
kjs a146667615 드래그 핸들 수정 2026-01-08 12:25:52 +09:00
SeongHyun Kim 2645d627da fix: 수주관리 수정 모달 저장 오류 수정 - UPDATE 폴백 로직 추가, 데이터 병합 순서 수정, 삭제 파라미터 순서 수정, id 타입 비교 통일 2026-01-08 12:25:35 +09:00
kjs f33d989202 복사된 셀 표시 2026-01-08 12:23:00 +09:00
kjs 6a1343b847 복사 붙여넣기 기능 2026-01-08 12:14:04 +09:00
kjs b61cb17aea 자동 채우기 핸들 2026-01-08 12:04:31 +09:00
kjs 83eb92cb27 엑셀 업로드 단계 통합 2026-01-08 11:51:02 +09:00
kjs 5321ea5b80 엑셀 업로드 템플릿 기능 구현 2026-01-08 11:45:39 +09:00
kjs d90a403ed9 엑셀 업로드 카테고리 타입 자동 감지 2026-01-08 11:09:40 +09:00
kjs c181385f11 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-08 10:40:01 +09:00
kjs 23ebae95d6 검색필터 틀고정기능 오류 수정 2026-01-08 10:39:48 +09:00
SeongHyun Kim 17498b1b2b refactor: UniversalFormModalComponent 자체 저장 기능 제거
saveSingleRow, saveWithCustomApi, handleSave, handleReset 함수 삭제
saving 상태 및 저장/초기화 버튼 UI 삭제
설정 패널에서 저장 버튼 관련 설정 UI 삭제
ModalConfig 타입에서 버튼 관련 속성 삭제
저장 처리는 button-primary (action: save)로 위임
약 468줄 코드 삭제
2026-01-08 10:04:05 +09:00
SeongHyun Kim 384106dd95 Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 18:27:00 +09:00
SeongHyun Kim 6f4c9b7fdd ix: 부모-자식 모달 데이터 전달 문제 해결 및 미사용 multiRowSave 기능 제거
InteractiveScreenViewerDynamic: 생성 모드에서 formData를 initialData로 전달하도록 수정
UniversalFormModal: saveMultipleRows 함수 및 multiRowSave 관련 코드 전체 제거
types/config: MultiRowSaveConfig 인터페이스 및 기본값 제거
FieldDetailSettingsModal: receiveFromParent UI 옵션 제거
SaveSettingsModal: 저장 모드 설명 개선
DB: multiRowSave.enabled=true인 화면 3개 설정 수정
2026-01-07 17:42:40 +09:00
kjs 26c61ee5b6 Merge pull request 'feature/screen-management' (#339) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/339
2026-01-07 16:11:15 +09:00
kjs d8ff49d1db Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-07 16:11:02 +09:00
kjs 8c525673ab Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-07 16:10:12 +09:00
kjs 47ac9ecd8a 범용폼모달 외부소스 지원 2026-01-07 16:10:11 +09:00
hjlee 1b633e55d2 Merge pull request 'lhj' (#338) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/338
2026-01-07 16:07:50 +09:00
DDD1542 b279f8d58d feat: 화면 관리 기능 개선 및 서브 테이블 정보 추가
- 화면 선택 시 그룹을 재설정하지 않도록 로직 개선하여 데이터 재로드 방지
- 테이블 노드 데이터 구조에 필드 매핑 정보 추가
- 서브 테이블과 조인 관계를 시각화하기 위한 컬럼 강조 및 스타일링 개선
- 화면 관계 흐름에서 서브 테이블 연결선 강조 기능 추가
- 사용 컬럼 및 조인 컬럼 정보를 화면별로 매핑하여 관리
2026-01-07 14:50:03 +09:00
DDD1542 48e9840fa0 feat: 화면 그룹 생성 및 업데이트 기능 개선
- 화면 그룹 생성 시 회사 코드 결정 로직 추가: 최고 관리자가 특정 회사를 선택할 수 있도록 변경
- 부모 그룹이 있는 경우 그룹 레벨 및 계층 경로 계산 로직 추가
- 화면 그룹 생성 후 계층 경로 업데이트 기능 구현
- 화면 그룹 업데이트 시 불필요한 코드 제거 및 최적화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 개선
2026-01-07 14:49:49 +09:00
leeheejin 62226918a7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-07 14:32:23 +09:00
leeheejin df70538027 pop화면 2026-01-07 14:31:04 +09:00
kjs 26020a29a0 Merge pull request 'feature/screen-management' (#337) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/337
2026-01-07 13:31:14 +09:00
kjs 52df163fbb Merge branch 'main' into feature/screen-management 2026-01-07 13:31:07 +09:00
kjs 777429af48 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-07 13:30:57 +09:00
kjs 856db80a36 빌드 에러수정 2026-01-07 13:30:57 +09:00
kjs cd7adce874 Merge pull request '테이블에 존재하는지 확인하는 제어 추가' (#336) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/336
2026-01-07 11:55:53 +09:00
kjs ca260aa260 Merge branch 'main' into feature/screen-management 2026-01-07 11:55:45 +09:00
SeongHyun Kim 42d1a3fc5e Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 10:28:47 +09:00
SeongHyun Kim 7c165a724e feat: 출고관리 수정 모달 저장 기능 개선 및 그룹화 컬럼 설정 UI 추가
ButtonConfigPanel: 수정 액션에 그룹화 컬럼 선택 드롭다운 추가 (영문/한글 검색 지원)
ScreenSplitPanel/EmbeddedScreen: groupedData prop 전달 경로 추가
buttonActions: RepeaterFieldGroup 저장 시 공통 필드 우선 적용되도록 병합 순서 변경
2026-01-07 10:24:01 +09:00
hjjeong 0ce0860dcc Merge pull request '입고 처리 시 재고 테이블 저장 및 재고이력 stock_id 전달 수정' (#335) from feature/inbound-inventory-fix into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/335
2026-01-07 10:06:37 +09:00
hjjeong c6ff839e54 입고 처리 시 재고 테이블 저장 및 재고이력 stock_id 전달 수정 2026-01-07 10:05:32 +09:00
kjs e308fd0ccc 테이블에 존재하는지 확인하는 제어 추가 2026-01-07 09:55:19 +09:00
hjlee f2cb7d14ca Merge pull request 'lhj' (#334) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/334
2026-01-07 09:48:46 +09:00
leeheejin b5b229122b Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-07 09:37:16 +09:00
leeheejin 126da9b46f 하이큐마그 점검항목 추가 후 주기명/점검방법명 오류 해결 2026-01-07 09:37:02 +09:00
SeongHyun Kim c365f06ed7 Merge remote-tracking branch 'origin/main' into ksh 2026-01-07 09:06:29 +09:00
hjlee 563081fa1c Merge pull request 'lhj' (#333) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/333
2026-01-06 17:57:31 +09:00
leeheejin 24331687d4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 17:57:05 +09:00
leeheejin ea848b97ee 검색필터 업데이트 2026-01-06 17:56:31 +09:00
hjlee 15fc166683 Merge pull request 'lhj' (#332) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/332
2026-01-06 17:40:25 +09:00
leeheejin 26fdab5b4e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 17:39:53 +09:00
leeheejin 12d3419b7f 구분 필터링 업데이트 2026-01-06 17:39:36 +09:00
SeongHyun Kim a2b701a4bf feat: 조건부 컨테이너 initialData 전달 체계 구현
InteractiveScreenViewerDynamic: originalData를 initialData로 추가 전달
DynamicComponentRenderer: initialData 우선순위 로직 추가
ConditionalContainerComponent: initialData props 추가 및 하위 전달
ConditionalSectionViewer: initialData props 추가 및 하위 전달
types.ts: initialData 타입 정의 추가
수정 모드에서 조건부 컨테이너 내부 컴포넌트 초기값 표시 지원
2026-01-06 17:29:41 +09:00
kjs 2213ad51b2 Merge pull request '수정' (#331) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/331
2026-01-06 17:02:54 +09:00
kjs 7120d5edc3 수정 2026-01-06 17:02:42 +09:00
kjs 0eb005ce35 Merge pull request '창고코드 같이 올라가게 수정' (#330) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/330
2026-01-06 15:34:02 +09:00
kjs 4828488c72 Merge branch 'main' into feature/screen-management 2026-01-06 15:33:55 +09:00
kjs c1425be57f 창고코드 같이 올라가게 수정 2026-01-06 15:33:44 +09:00
SeongHyun Kim 25b7e637de feat: 테이블 데이터 저장 시 존재하지 않는 컬럼 자동 필터링
- tableManagementService.addTableData: 테이블 스키마 기반 컬럼 필터링 로직 추가
- 무시된 컬럼 정보를 API 응답에 포함 (skippedColumns, savedColumns)
- 프론트엔드 콘솔에 무시된 컬럼 경고 출력
- conditional-container의 UI 제어용 필드(condition) 등으로 인한 저장 에러 방지
2026-01-06 15:29:26 +09:00
SeongHyun Kim ad39374e54 Merge remote-tracking branch 'origin/main' into ksh 2026-01-06 15:04:36 +09:00
SeongHyun Kim 77bb917248 feat: RepeaterFieldGroup 상위 폼 필드 전달 방식 개선
- 하드코딩된 masterDetailFields 배열을 규칙 기반 필터링으로 변경
- 제외 규칙: comp_ 접두사, _numberingRuleId 접미사, 배열/객체 타입, 빈 값 등
- 새 필드 추가 시 코드 수정 불필요하도록 개선
- 에러 로깅 상세 정보 추가 (status, data, message, fullError)
2026-01-06 15:03:22 +09:00
hjjeong 6bf914d9b1 Merge pull request '거래처 품목정보 거래처품번/단가 입력 없이 저장되도록' (#329) from fix/daejin-bugs into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/329
2026-01-06 15:02:38 +09:00
hjjeong e08c50c771 거래처 품목정보 거래처품번/단가 입력 없이 저장되도록 2026-01-06 15:01:50 +09:00
hjlee 0f027f2382 Merge pull request 'lhj' (#328) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/328
2026-01-06 14:45:07 +09:00
leeheejin 09d574fb8a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 14:44:23 +09:00
leeheejin 6ae0778b4c 필터의 라벨도 코드말고 설정한걸로 나오게 수정 2026-01-06 14:43:57 +09:00
kjs 58b0e1b79b Merge pull request '범용 폼모달 라벨로 뜨게 수정' (#327) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/327
2026-01-06 14:24:52 +09:00
kjs f0322a49ad 범용 폼모달 라벨로 뜨게 수정 2026-01-06 14:24:30 +09:00
kjs 5e27d21257 Merge pull request 'feature/screen-management' (#326) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/326
2026-01-06 13:46:00 +09:00
kjs efc9175fec Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-06 13:45:48 +09:00
kjs eb61506acd 분할패널 수정동작 수정 2026-01-06 13:43:47 +09:00
SeongHyun Kim 75b5530d04 Merge remote-tracking branch 'origin/main' into ksh 2026-01-06 13:23:00 +09:00
kjs cded99d644 로그 제거 2026-01-06 13:08:33 +09:00
SeongHyun Kim 40fd5f9055 feat: 채번규칙 editable 옵션 수동 모드 감지 기능 구현
모달 오픈 시 채번 미리보기 원본값 저장 (numberingOriginalValues)
handleFieldChange에서 원본값 비교하여 수동/자동 모드 전환
사용자 수정 시 ruleId 제거하여 저장 시 채번 스킵
원본값 복구 시 ruleId 복구하여 자동 모드 복원
handleSave에서 채번 할당 조건 분기 처리
2026-01-06 13:06:28 +09:00
kjs 0709b8df25 Merge pull request '범용 폼 모달 사전필터 기능 수정' (#325) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/325
2026-01-06 11:43:38 +09:00
kjs 6bfc1a97a3 범용 폼 모달 사전필터 기능 수정 2026-01-06 11:43:26 +09:00
kjs 9ea0f1b84f Merge pull request 'feature/screen-management' (#324) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/324
2026-01-06 10:28:30 +09:00
kjs 7cb8026979 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-06 10:28:17 +09:00
kjs 4f77c38207 권한기능 임시 비활성화 2026-01-06 10:27:54 +09:00
leeheejin 68017ed0e9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-06 10:21:07 +09:00
hjjeong 338c885cfa Merge pull request 'fix/daejin-bugs' (#323) from fix/daejin-bugs into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/323
2026-01-06 09:20:44 +09:00
SeongHyun Kim b3ee2b50e8 fix: 카테고리 Select 필드 저장 시 라벨값 대신 코드값 저장되도록 수정
- UniversalFormModalComponent.tsx: 카테고리 옵션 value를 valueLabel에서 valueCode로 변경
- 제어 로직 조건 비교 정상화 및 500 에러 해결
2026-01-05 18:41:49 +09:00
hjjeong 64105bf525 발주관리 수정시 공급처 표시 오류 수정 2026-01-05 18:21:29 +09:00
DDD1542 6925e3af3f feat: 화면 서브 테이블 정보 조회 기능 추가
- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현
- 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가
- 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
2026-01-05 18:18:26 +09:00
hjjeong 714698c20f 구매관리_발주관리 저장 시 마스터정보 품목에 전달 2026-01-05 17:08:47 +09:00
hjjeong 2a7066b6fd 테이블에 존재하는 컬럼만 업데이트 2026-01-05 17:08:03 +09:00
leeheejin 5fbc76f85d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-05 14:47:26 +09:00
SeongHyun Kim e747162058 Merge remote-tracking branch 'origin/main' into ksh 2026-01-05 13:58:56 +09:00
SeongHyun Kim 914f3d57f3 fix: TableList 카테고리 라벨 표시를 위한 멀티테넌시 fallback 로직 추가
getColumnInputTypes API에서 회사별 설정이 없을 때 기본설정() fallback 적용
table_type_columns, category_column_mapping 조회 시 DISTINCT ON + ORDER BY CASE WHEN 패턴 사용
영향 범위: 모든 TableList 컴포넌트의 카테고리 컬럼 표시
2026-01-05 13:58:13 +09:00
kjs 12baad75c9 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2026-01-05 13:54:42 +09:00
kjs 85519e302f 행선택시에만 버튼 활성화 기능 수정 2026-01-05 13:54:41 +09:00
kjs 98e96a1fb0 Merge pull request '즉시저장 액션 필수항목 체크 추가' (#322) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/322
2026-01-05 13:38:09 +09:00
kjs 76d7d5149b Merge branch 'main' into feature/screen-management 2026-01-05 13:38:02 +09:00
kjs 239e4800c7 즉시저장 액션 필수항목 체크 추가 2026-01-05 13:37:39 +09:00
leeheejin 6c75adb61d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-05 10:10:23 +09:00
DDD1542 7caf2dea94 feat: 화면 그룹 관리 기능 추가
- 화면 그룹 CRUD API 및 라우트 구현
- 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가
- 화면-그룹 연결 및 데이터 흐름 관리 기능 포함
- 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
2026-01-05 10:05:31 +09:00
SeongHyun Kim ad76bfe3b0 Merge remote-tracking branch 'origin/main' into ksh 2026-01-05 09:28:12 +09:00
SeongHyun Kim 4ad58ba942 feat: 폼 모달 자동 채번 기능 구현 및 임베디드 스크린 렌더링 최적화
UniversalFormModalComponent에 폼 초기화 시 채번 규칙을 가져와 적용하는 generateNumberingValues 구현
채번 생성 중복 호출 방지를 위한 useRef 로직 추가
데이터 업데이트 시 불필요한 리마운트 및 포커스 분실을 방지하기 위해 EmbeddedScreen 컴포넌트 key에서 formDataVersion 제거
2026-01-04 17:41:07 +09:00
DDD1542 36a723b1a0 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-01-01 02:29:57 +09:00
DDD1542 5a94afc1d5 기존 회사변경수정 2026-01-01 02:29:53 +09:00
leeheejin 2889e4c82c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-31 14:53:06 +09:00
kjs c15ec8f7b9 Merge pull request 'feature/screen-management' (#321) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/321
2025-12-31 14:17:53 +09:00
kjs eb868965df 조건부 계산식 2025-12-31 14:17:39 +09:00
kjs 417e1d297b 폼 조건별 계산식 설정기능 2025-12-31 13:53:30 +09:00
leeheejin 0b1dc98e5c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-31 11:01:18 +09:00
kjs fff10a1911 Merge pull request '범용 폼모달 제어로직 연동' (#320) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/320
2025-12-31 10:54:28 +09:00
kjs 2842930dba Merge branch 'main' into feature/screen-management 2025-12-31 10:54:20 +09:00
kjs 5bdc903b0d 범용 폼모달 제어로직 연동 2025-12-31 10:54:07 +09:00
kjs 4ce0411809 Merge pull request 'feature/screen-management' (#319) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/319
2025-12-30 17:45:50 +09:00
kjs bd49db16c6 1 2025-12-30 17:45:38 +09:00
kjs 113ef24bdf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-30 15:51:26 +09:00
kjs 7d6bff49aa 폼 채번 오작동 문제 수정 2025-12-30 15:36:28 +09:00
DDD1542 7b773f57b4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2025-12-30 15:28:13 +09:00
DDD1542 58233e51de 각각 별도 TSX 병합 및 회사선택기능 추가 2025-12-30 15:28:05 +09:00
kjs 4421ccaa71 Merge pull request 'feature/screen-management' (#318) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/318
2025-12-30 14:19:44 +09:00
kjs 183f68e89a Merge branch 'main' into feature/screen-management 2025-12-30 14:19:36 +09:00
kjs fb82d2f5a1 분할패널에서 버튼 비활성화기능 수정 2025-12-30 14:19:15 +09:00
kjs 84a3956b02 버튼 버그 수정 2025-12-30 14:13:26 +09:00
kjs c78326bae1 버튼 비활성화 로직 2025-12-30 14:11:42 +09:00
kjs b45f4870e8 신규등록 버튼 버그 수정 2025-12-30 14:03:29 +09:00
kjs fd58e9cce2 행추가,모달 동시입력기능 구현 2025-12-30 13:32:49 +09:00
kjs c7efe8ec33 Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-12-30 12:33:18 +09:00
kjs 3c4e251e9b 폼 다중테이블 저장 지원 2025-12-30 12:33:17 +09:00
kjs e902987e44 Merge pull request '로그테이블 관련 내용 추가' (#317) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/317
2025-12-30 10:54:31 +09:00
kjs 54ca51258c Merge branch 'main' into feature/screen-management 2025-12-30 10:54:23 +09:00
kjs 06d5069566 로그테이블 관련 내용 추가 2025-12-30 10:54:06 +09:00
kjs e1d6c1740f Merge pull request '테이블 생성규칙' (#316) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/316
2025-12-30 10:49:01 +09:00
kjs c32bd8a4bf 테이블 생성규칙 2025-12-30 10:48:11 +09:00
leeheejin e040b94a62 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-30 09:41:56 +09:00
DDD1542 6476a83d86 변경추가 2025-12-29 18:19:17 +09:00
DDD1542 ea3b6d2083 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2025-12-29 17:58:27 +09:00
DDD1542 87caa4b3ca 커밋 메세지 메뉴별 대중소 정리 2025-12-29 17:56:26 +09:00
hyeonsu e30b1cc01a Merge pull request '마지막 merge' (#315) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/315
2025-12-29 17:53:24 +09:00
leeheejin 22e0ce1fc5 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-29 15:54:07 +09:00
leeheejin 6bd25c8a9e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-24 09:25:29 +09:00
leeheejin 67471b2518 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-23 17:20:32 +09:00
leeheejin f1c4891924 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2025-12-22 15:31:17 +09:00
leeheejin 109380b9e5 이제 디비에 한글로 출발지 목적지 저장되도록 2025-12-15 17:01:04 +09:00
294 changed files with 67638 additions and 8305 deletions

View File

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

View File

@ -0,0 +1,592 @@
# 테이블 타입 관리 SQL 작성 가이드
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
## 핵심 원칙
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
---
## 1. 테이블 생성 DDL 템플릿
### 기본 구조
```sql
CREATE TABLE "테이블명" (
-- 시스템 기본 컬럼 (자동 포함)
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
"컬럼1" varchar(500),
"컬럼2" varchar(500),
"컬럼3" varchar(500)
);
```
### 예시: 고객 테이블 생성
```sql
CREATE TABLE "customer_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"customer_name" varchar(500),
"customer_code" varchar(500),
"phone" varchar(500),
"email" varchar(500),
"address" varchar(500),
"status" varchar(500),
"registration_date" varchar(500)
);
```
---
## 2. 메타데이터 테이블 등록
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
### 2.1 table_labels (테이블 메타데이터)
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### 2.2 table_type_columns (컬럼 타입 정보)
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
```sql
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
display_order = EXCLUDED.display_order,
updated_date = now();
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
display_order = EXCLUDED.display_order,
updated_date = now();
```
### 2.3 column_labels (레거시 호환용 - 필수)
```sql
-- 기본 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
-- 사용자 정의 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
```
---
## 3. Input Type 정의
### 지원되는 Input Type 목록
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
| ---------- | ------------- | ------------ | -------------------- |
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
| `select` | 선택 목록 | VARCHAR(500) | Select |
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
### WebType → InputType 변환 규칙
```
text, textarea, email, tel, url, password → text
number, decimal → number
date, datetime, time → date
select, dropdown → select
checkbox, boolean → checkbox
radio → radio
code → code
entity → entity
file → text
button → text
```
---
## 4. Detail Settings 설정
### 4.1 Code 타입 (공통코드 참조)
```json
{
"codeCategory": "코드_카테고리_ID"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
```
### 4.2 Entity 타입 (테이블 참조)
```json
{
"referenceTable": "참조_테이블명",
"referenceColumn": "참조_컬럼명(보통 id)",
"displayColumn": "표시할_컬럼명"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
```
### 4.3 Select 타입 (정적 옵션)
```json
{
"options": [
{ "label": "옵션1", "value": "value1" },
{ "label": "옵션2", "value": "value2" }
]
}
```
---
## 5. 전체 예시: 주문 테이블 생성
### Step 1: DDL 실행
```sql
CREATE TABLE "order_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"order_no" varchar(500),
"order_date" varchar(500),
"customer_id" varchar(500),
"total_amount" varchar(500),
"status" varchar(500),
"notes" varchar(500)
);
```
### Step 2: table_labels 등록
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### Step 3: table_type_columns 등록
```sql
-- 기본 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
```
### Step 4: column_labels 등록 (레거시 호환)
```sql
-- 기본 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
```
---
## 6. 컬럼 추가 시
### DDL
```sql
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
```
### 메타데이터 등록
```sql
-- table_type_columns
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- column_labels
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
```
---
## 7. 로그 테이블 생성 (선택사항)
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
### 7.1 로그 테이블 DDL 템플릿
```sql
-- 로그 테이블 생성
CREATE TABLE 테이블명_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
original_id VARCHAR(100), -- 원본 테이블 PK 값
changed_column VARCHAR(100), -- 변경된 컬럼명
old_value TEXT, -- 변경 전 값
new_value TEXT, -- 변경 후 값
changed_by VARCHAR(50), -- 변경자 ID
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
ip_address VARCHAR(50), -- 변경 요청 IP
user_agent TEXT, -- User Agent
full_row_before JSONB, -- 변경 전 전체 행
full_row_after JSONB -- 변경 후 전체 행
);
-- 인덱스 생성
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
-- 코멘트 추가
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
```
### 7.2 트리거 함수 DDL 템플릿
```sql
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '테이블명'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO 테이블명_log (
operation_type, original_id, changed_column, old_value, new_value,
changed_by, ip_address, full_row_before, full_row_after
)
VALUES (
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
```
### 7.3 트리거 DDL 템플릿
```sql
CREATE TRIGGER 테이블명_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
```
### 7.4 로그 설정 등록
```sql
INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_by, created_at
) VALUES (
'테이블명', '테이블명_log', '테이블명_audit_trigger',
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
);
```
### 7.5 table_labels에 use_log_table 플래그 설정
```sql
UPDATE table_labels
SET use_log_table = 'Y', updated_date = now()
WHERE table_name = '테이블명';
```
### 7.6 전체 예시: order_info 로그 테이블 생성
```sql
-- Step 1: 로그 테이블 생성
CREATE TABLE order_info_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
-- Step 2: 트리거 함수 생성
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name FROM information_schema.columns
WHERE table_name = 'order_info' AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Step 3: 트리거 생성
CREATE TRIGGER order_info_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_info
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
-- Step 4: 로그 설정 등록
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
-- Step 5: table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
```
### 7.7 로그 테이블 삭제
```sql
-- 트리거 삭제
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
-- 트리거 함수 삭제
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
-- 로그 테이블 삭제
DROP TABLE IF EXISTS 테이블명_log;
-- 로그 설정 삭제
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
-- table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
```
---
## 8. 체크리스트
### 테이블 생성/수정 시 반드시 확인할 사항:
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
- [ ] `table_labels`에 테이블 메타데이터 등록
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
- [ ] 기본 컬럼 display_order: -5 ~ -1
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
### 로그 테이블 생성 시 확인할 사항 (선택):
- [ ] 로그 테이블 생성 (`테이블명_log`)
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
- [ ] `table_log_config`에 로그 설정 등록
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
---
## 9. 금지 사항
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
---
## 참조 파일
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

70
PLAN.MD
View File

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

View File

@ -42,6 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -1043,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -2370,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@ -3214,6 +3217,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/bwip-js": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/compression": { "node_modules/@types/compression": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
@ -3463,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -3699,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@ -3916,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -4442,6 +4458,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -5652,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -7414,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -8383,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@ -9272,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@ -10122,7 +10141,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@ -10931,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@ -11036,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -56,6 +56,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",

View File

@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
@ -72,6 +73,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@ -196,6 +198,7 @@ app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes); app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/common-codes", commonCodeRoutes); app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes); app.use("/api/files", fileRoutes);
@ -220,6 +223,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes); app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes); app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음

View File

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

View File

@ -141,6 +141,110 @@ export class AuthController {
} }
} }
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
static async switchCompany(req: Request, res: Response): Promise<void> {
try {
const { companyCode } = req.body;
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({
success: false,
message: "인증 토큰이 필요합니다.",
error: { code: "TOKEN_MISSING" },
});
return;
}
// 현재 사용자 정보 확인
const currentUser = JwtUtils.verifyToken(token);
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
if (currentUser.userType !== "SUPER_ADMIN") {
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
res.status(403).json({
success: false,
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
error: { code: "FORBIDDEN" },
});
return;
}
// 전환할 회사 코드 검증
if (!companyCode || companyCode.trim() === "") {
res.status(400).json({
success: false,
message: "전환할 회사 코드가 필요합니다.",
error: { code: "INVALID_INPUT" },
});
return;
}
logger.info(`=== WACE 관리자 회사 전환 ===`, {
userId: currentUser.userId,
originalCompanyCode: currentUser.companyCode,
targetCompanyCode: companyCode,
});
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
if (companyCode !== "*") {
const { query } = await import("../database/db");
const companies = await query<any>(
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
[companyCode]
);
if (companies.length === 0) {
res.status(404).json({
success: false,
message: "존재하지 않는 회사 코드입니다.",
error: { code: "COMPANY_NOT_FOUND" },
});
return;
}
}
// 새로운 JWT 토큰 발급 (company_code만 변경)
const newPersonBean: PersonBean = {
...currentUser,
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
};
const newToken = JwtUtils.generateToken(newPersonBean);
logger.info(`✅ 회사 전환 성공: ${currentUser.userId}${companyCode}`);
res.status(200).json({
success: true,
message: "회사 전환 완료",
data: {
token: newToken,
companyCode: companyCode.trim(),
},
});
} catch (error) {
logger.error(
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "회사 전환 중 오류가 발생했습니다.",
error: {
code: "SERVER_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/** /**
* POST /api/auth/logout * POST /api/auth/logout
* Java ApiLoginController.logout() * Java ApiLoginController.logout()
@ -226,13 +330,14 @@ export class AuthController {
} }
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환 // 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
const userInfoResponse: any = { const userInfoResponse: any = {
userId: dbUserInfo.userId, userId: dbUserInfo.userId,
userName: dbUserInfo.userName || "", userName: dbUserInfo.userName || "",
deptName: dbUserInfo.deptName || "", deptName: dbUserInfo.deptName || "",
companyCode: dbUserInfo.companyCode || "ILSHIN", companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성 company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
userType: dbUserInfo.userType || "USER", userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자", userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "", email: dbUserInfo.email || "",
photo: dbUserInfo.photo, photo: dbUserInfo.photo,

View File

@ -282,3 +282,175 @@ export async function previewCodeMerge(
} }
} }
/**
* -
* oldValue를 newValue로
*/
export async function mergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("값 기반 코드 병합 시작", {
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_by_value($1, $2, $3)",
[oldValue, newValue, companyCode]
);
// 결과 처리
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = affectedData.reduce(
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
0
);
logger.info("값 기반 코드 병합 완료", {
oldValue,
newValue,
affectedTablesCount: affectedData.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
oldValue,
newValue,
affectedData: affectedData.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
rowsUpdated: parseInt(row.out_rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 실패:", {
error: error.message,
stack: error.stack,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_BY_VALUE_ERROR",
details: error.message,
},
});
}
}
/**
*
* /
*/
export async function previewMergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM preview_merge_code_by_value($1, $2)",
[oldValue, companyCode]
);
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = preview.reduce(
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
0
);
logger.info("값 기반 코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
oldValue,
preview: preview.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
affectedRows: parseInt(row.out_affected_rows),
})),
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_BY_VALUE_ERROR",
details: error.message,
},
});
}
}

View File

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

View File

@ -66,11 +66,23 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode"; const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField]; const userValue = ((req as any).user as any)[userField];
if (userValue) { // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
searchConditions[filterColumn] = userValue; let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn, filterColumn,
userValue, finalCompanyCode,
tableName, tableName,
}); });
} }

View File

@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
} }
// 추가 필터 조건 (존재하는 컬럼만) // 추가 필터 조건 (존재하는 컬럼만)
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
// 특수 키 형식: column__operator (예: division__in, name__like)
const additionalFilter = JSON.parse(filterCondition as string); const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) { for (const [key, value] of Object.entries(additionalFilter)) {
if (existingColumns.has(key)) { // 특수 키 형식 파싱: column__operator
whereConditions.push(`${key} = $${paramIndex}`); let columnName = key;
params.push(value); let operator = "=";
paramIndex++;
} else { if (key.includes("__")) {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); const parts = key.split("__");
columnName = parts[0];
operator = parts[1] || "=";
}
if (!existingColumns.has(columnName)) {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
continue;
}
// 연산자별 WHERE 조건 생성
switch (operator) {
case "=":
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${columnName}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
whereConditions.push(`"${columnName}" > $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<":
whereConditions.push(`"${columnName}" < $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">=":
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<=":
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in":
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inValues.length > 0) {
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" IN (${placeholders})`);
params.push(...inValues);
paramIndex += inValues.length;
}
break;
case "notIn":
// NOT IN 연산자
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInValues.length > 0) {
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
params.push(...notInValues);
paramIndex += notInValues.length;
}
break;
case "like":
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
default:
// 알 수 없는 연산자는 등호로 처리
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
} }
} }

View File

@ -0,0 +1,208 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import excelMappingService from "../services/excelMappingService";
import { logger } from "../utils/logger";
/**
* 릿
* POST /api/excel-mapping/find
*/
export async function findMappingByColumns(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
res.status(400).json({
success: false,
message: "tableName과 excelColumns(배열)가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 조회 요청", {
tableName,
excelColumns,
companyCode,
userId: req.user?.userId,
});
const template = await excelMappingService.findMappingByColumns(
tableName,
excelColumns,
companyCode
);
if (template) {
res.json({
success: true,
data: template,
message: "기존 매핑 템플릿을 찾았습니다.",
});
} else {
res.json({
success: true,
data: null,
message: "일치하는 매핑 템플릿이 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿 (UPSERT)
* POST /api/excel-mapping/save
*/
export async function saveMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns, columnMappings } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!tableName || !excelColumns || !columnMappings) {
res.status(400).json({
success: false,
message: "tableName, excelColumns, columnMappings가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 저장 요청", {
tableName,
excelColumns,
columnMappings,
companyCode,
userId,
});
const template = await excelMappingService.saveMappingTemplate(
tableName,
excelColumns,
columnMappings,
companyCode,
userId
);
res.json({
success: true,
data: template,
message: "매핑 템플릿이 저장되었습니다.",
});
} catch (error: any) {
logger.error("매핑 템플릿 저장 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* GET /api/excel-mapping/list/:tableName
*/
export async function getMappingTemplates(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!tableName) {
res.status(400).json({
success: false,
message: "tableName이 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 목록 조회 요청", {
tableName,
companyCode,
});
const templates = await excelMappingService.getMappingTemplates(
tableName,
companyCode
);
res.json({
success: true,
data: templates,
});
} catch (error: any) {
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* DELETE /api/excel-mapping/:id
*/
export async function deleteMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!id) {
res.status(400).json({
success: false,
message: "id가 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 삭제 요청", {
id,
companyCode,
});
const deleted = await excelMappingService.deleteMappingTemplate(
parseInt(id),
companyCode
);
if (deleted) {
res.json({
success: true,
message: "매핑 템플릿이 삭제되었습니다.",
});
} else {
res.status(404).json({
success: false,
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

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

View File

@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { ruleId } = req.params; const { ruleId } = req.params;
logger.info("코드 할당 요청", { ruleId, companyCode });
try { try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } }); return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) { } catch (error: any) {
logger.error("코드 할당 실패", { error: error.message }); logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -775,13 +775,25 @@ export async function getTableData(
const userField = autoFilter?.userField || "companyCode"; const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField]; const userValue = (req.user as any)[userField];
if (userValue) { // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
enhancedSearch[filterColumn] = userValue; let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", { logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn, filterColumn,
userField, userField,
userValue, userValue: finalCompanyCode,
tableName, tableName,
}); });
} else { } else {
@ -2167,3 +2179,104 @@ export async function multiTableSave(
} }
} }
/**
*
* column_labels의 entity/category
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
if (!leftTable || !rightTable) {
res.status(400).json({
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
});
return;
}
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
// 두 테이블의 컬럼 라벨 정보 조회
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
web_type,
detail_settings
FROM column_labels
WHERE table_name IN ($1, $2)
AND web_type IN ('entity', 'category')
`;
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true,
data: {
leftTable,
rightTable,
relations,
},
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
}
}

View File

@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken);
*/ */
router.post("/signup", AuthController.signup); router.post("/signup", AuthController.signup);
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
router.post("/switch-company", AuthController.switchCompany);
export default router; export default router;

View File

@ -55,3 +55,5 @@ export default router;

View File

@ -51,3 +51,5 @@ export default router;

View File

@ -67,3 +67,5 @@ export default router;

View File

@ -55,3 +55,5 @@ export default router;

View File

@ -3,6 +3,8 @@ import {
mergeCodeAllTables, mergeCodeAllTables,
getTablesWithColumn, getTablesWithColumn,
previewCodeMerge, previewCodeMerge,
mergeCodeByValue,
previewMergeCodeByValue,
} from "../controllers/codeMergeController"; } from "../controllers/codeMergeController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -13,7 +15,7 @@ router.use(authenticateToken);
/** /**
* POST /api/code-merge/merge-all-tables * POST /api/code-merge/merge-all-tables
* ( ) * ( - )
* Body: { columnName, oldValue, newValue } * Body: { columnName, oldValue, newValue }
*/ */
router.post("/merge-all-tables", mergeCodeAllTables); router.post("/merge-all-tables", mergeCodeAllTables);
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
/** /**
* POST /api/code-merge/preview * POST /api/code-merge/preview
* ( ) * ( )
* Body: { columnName, oldValue } * Body: { columnName, oldValue }
*/ */
router.post("/preview", previewCodeMerge); router.post("/preview", previewCodeMerge);
/**
* POST /api/code-merge/merge-by-value
* ( )
* Body: { oldValue, newValue }
*/
router.post("/merge-by-value", mergeCodeByValue);
/**
* POST /api/code-merge/preview-by-value
* ( )
* Body: { oldValue }
*/
router.post("/preview-by-value", previewMergeCodeByValue);
export default router; export default router;

View File

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

View File

@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
} }
}); });
/**
*
* GET /api/dataflow/node-flows/:flowId/source-table
* (tableSource, externalDBSource)
*/
router.get("/:flowId/source-table", async (req: Request, res: Response) => {
try {
const { flowId } = req.params;
const flow = await queryOne<{ flow_data: any }>(
`SELECT flow_data FROM node_flows WHERE flow_id = $1`,
[flowId]
);
if (!flow) {
return res.status(404).json({
success: false,
message: "플로우를 찾을 수 없습니다.",
});
}
const flowData =
typeof flow.flow_data === "string"
? JSON.parse(flow.flow_data)
: flow.flow_data;
const nodes = flowData.nodes || [];
// 소스 노드 찾기 (tableSource, externalDBSource 타입)
const sourceNode = nodes.find(
(node: any) =>
node.type === "tableSource" || node.type === "externalDBSource"
);
if (!sourceNode || !sourceNode.data?.tableName) {
return res.json({
success: true,
data: {
sourceTable: null,
sourceNodeType: null,
message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
},
});
}
logger.info(
`플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
);
return res.json({
success: true,
data: {
sourceTable: sourceNode.data.tableName,
sourceNodeType: sourceNode.type,
sourceNodeId: sourceNode.id,
displayName: sourceNode.data.displayName,
},
});
} catch (error) {
logger.error("플로우 소스 테이블 조회 실패:", error);
return res.status(500).json({
success: false,
message: "플로우 소스 테이블을 조회하지 못했습니다.",
});
}
});
/** /**
* *
* POST /api/dataflow/node-flows/:flowId/execute * POST /api/dataflow/node-flows/:flowId/execute

View File

@ -0,0 +1,25 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
findMappingByColumns,
saveMappingTemplate,
getMappingTemplates,
deleteMappingTemplate,
} from "../controllers/excelMappingController";
const router = Router();
// 엑셀 컬럼 구조로 매핑 템플릿 조회
router.post("/find", authenticateToken, findMappingByColumns);
// 매핑 템플릿 저장 (UPSERT)
router.post("/save", authenticateToken, saveMappingTemplate);
// 테이블의 매핑 템플릿 목록 조회
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
// 매핑 템플릿 삭제
router.delete("/:id", authenticateToken, deleteMappingTemplate);
export default router;

View File

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

View File

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

View File

@ -25,6 +25,7 @@ import {
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -38,6 +39,15 @@ router.use(authenticateToken);
*/ */
router.get("/tables", getTableList); router.get("/tables", getTableList);
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/
router.get("/tables/entity-relations", getTableEntityRelations);
/** /**
* *
* GET /api/table-management/tables/:tableName/columns * GET /api/table-management/tables/:tableName/columns

View File

@ -65,6 +65,13 @@ export class AdminService {
} }
); );
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
/* [ - ]
if (userType === "COMPANY_ADMIN") { if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 권한 그룹 기반 필터링 적용 // 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) { if (userRoleGroups.length > 0) {
@ -141,6 +148,7 @@ export class AdminService {
return []; return [];
} }
} }
*/
} else if ( } else if (
menuType !== undefined && menuType !== undefined &&
userType === "SUPER_ADMIN" && userType === "SUPER_ADMIN" &&
@ -412,9 +420,18 @@ export class AdminService {
let queryParams: any[] = [userLang]; let queryParams: any[] = [userLang];
let paramIndex = 2; let paramIndex = 2;
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시 // TODO: 권한 체크 다시 활성화 필요
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); logger.info(
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
authFilter = "";
unionFilter = "";
/* [ - getUserMenuList ]
if (userType === "SUPER_ADMIN") {
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
authFilter = ""; authFilter = "";
unionFilter = ""; unionFilter = "";
} else { } else {
@ -471,6 +488,7 @@ export class AdminService {
return []; return [];
} }
} }
*/
// 2. 회사별 필터링 조건 생성 // 2. 회사별 필터링 조건 생성
let companyFilter = ""; let companyFilter = "";

View File

@ -254,7 +254,10 @@ class DataService {
key !== "limit" && key !== "limit" &&
key !== "offset" && key !== "offset" &&
key !== "orderBy" && key !== "orderBy" &&
key !== "userLang" key !== "userLang" &&
key !== "page" &&
key !== "pageSize" &&
key !== "size"
) { ) {
// 컬럼명 검증 (SQL 인젝션 방지) // 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
@ -1189,6 +1192,13 @@ class DataService {
[tableName] [tableName]
); );
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
pkColumns: pkResult.map((r) => r.attname),
pkCount: pkResult.length,
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
inputIdType: typeof id,
});
let whereClauses: string[] = []; let whereClauses: string[] = [];
let params: any[] = []; let params: any[] = [];
@ -1216,17 +1226,31 @@ class DataService {
params.push(typeof id === "object" ? id[pkColumn] : id); params.push(typeof id === "object" ? id[pkColumn] : id);
} }
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params); console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params); const result = await query<any>(queryText, params);
// 삭제된 행이 없으면 실패 처리
if (result.length === 0) {
console.warn(
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
{ whereClauses, params }
);
return {
success: false,
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
console.log( console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
); );
return { return {
success: true, success: true,
data: result[0], // 삭제된 레코드 정보 반환
}; };
} catch (error) { } catch (error) {
console.error(`레코드 삭제 오류 (${tableName}):`, error); console.error(`레코드 삭제 오류 (${tableName}):`, error);
@ -1240,10 +1264,14 @@ class DataService {
/** /**
* ( ) * ( )
* @param tableName
* @param filterConditions
* @param userCompany ( )
*/ */
async deleteGroupRecords( async deleteGroupRecords(
tableName: string, tableName: string,
filterConditions: Record<string, any> filterConditions: Record<string, any>,
userCompany?: string
): Promise<ServiceResponse<{ deleted: number }>> { ): Promise<ServiceResponse<{ deleted: number }>> {
try { try {
const validation = await this.validateTableAccess(tableName); const validation = await this.validateTableAccess(tableName);
@ -1255,6 +1283,7 @@ class DataService {
const whereValues: any[] = []; const whereValues: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 사용자 필터 조건 추가
for (const [key, value] of Object.entries(filterConditions)) { for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`); whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value); whereValues.push(value);
@ -1269,10 +1298,24 @@ class DataService {
}; };
} }
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode && userCompany && userCompany !== "*") {
whereConditions.push(`"company_code" = $${paramIndex}`);
whereValues.push(userCompany);
paramIndex++;
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
}
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); console.log(`🗑️ 그룹 삭제:`, {
tableName,
conditions: filterConditions,
userCompany,
whereClause,
});
const result = await pool.query(deleteQuery, whereValues); const result = await pool.query(deleteQuery, whereValues);

View File

@ -1,6 +1,7 @@
import { query, queryOne, transaction, getPool } from "../database/db"; import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService"; import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService"; import { DataflowControlService } from "./dataflowControlService";
import tableCategoryValueService from "./tableCategoryValueService";
export interface FormDataResult { export interface FormDataResult {
id: number; id: number;
@ -427,6 +428,24 @@ export class DynamicFormService {
dataToInsert, dataToInsert,
}); });
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
const companyCodeForCategory = company_code || "*";
const { convertedData: categoryConvertedData, conversions } =
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
tableName,
companyCodeForCategory,
dataToInsert
);
if (conversions.length > 0) {
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}`, conversions);
// 변환된 데이터로 교체
Object.assign(dataToInsert, categoryConvertedData);
} else {
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
}
// 테이블 컬럼 정보 조회하여 타입 변환 적용 // 테이블 컬럼 정보 조회하여 타입 변환 적용
console.log("🔍 테이블 컬럼 정보 조회 중..."); console.log("🔍 테이블 컬럼 정보 조회 중...");
const columnInfo = await this.getTableColumnInfo(tableName); const columnInfo = await this.getTableColumnInfo(tableName);
@ -1173,12 +1192,18 @@ export class DynamicFormService {
/** /**
* ( ) * ( )
* @param id ID
* @param tableName
* @param companyCode
* @param userId ID
* @param screenId ID ( , )
*/ */
async deleteFormData( async deleteFormData(
id: string | number, id: string | number,
tableName: string, tableName: string,
companyCode?: string, companyCode?: string,
userId?: string userId?: string,
screenId?: number
): Promise<void> { ): Promise<void> {
try { try {
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
@ -1291,14 +1316,19 @@ export class DynamicFormService {
const recordCompanyCode = const recordCompanyCode =
deletedRecord?.company_code || companyCode || "*"; deletedRecord?.company_code || companyCode || "*";
await this.executeDataflowControlIfConfigured( // screenId가 전달되지 않으면 제어관리를 실행하지 않음
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) if (screenId && screenId > 0) {
tableName, await this.executeDataflowControlIfConfigured(
deletedRecord, screenId,
"delete", tableName,
userId || "system", deletedRecord,
recordCompanyCode "delete",
); userId || "system",
recordCompanyCode
);
} else {
console.log(" screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
}
} }
} catch (controlError) { } catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError); console.error("⚠️ 제어관리 실행 오류:", controlError);
@ -1643,10 +1673,16 @@ export class DynamicFormService {
!!properties?.webTypeConfig?.dataflowConfig?.flowControls, !!properties?.webTypeConfig?.dataflowConfig?.flowControls,
}); });
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 // 버튼 컴포넌트이고 제어관리가 활성화된 경우
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
const buttonActionType = properties?.componentConfig?.action?.type;
const isMatchingAction =
(triggerType === "delete" && buttonActionType === "delete") ||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
if ( if (
properties?.componentType === "button-primary" && properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" && isMatchingAction &&
properties?.webTypeConfig?.enableDataflowControl === true properties?.webTypeConfig?.enableDataflowControl === true
) { ) {
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;

View File

@ -0,0 +1,283 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import crypto from "crypto";
export interface ExcelMappingTemplate {
id?: number;
tableName: string;
excelColumns: string[];
excelColumnsHash: string;
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
companyCode: string;
createdDate?: Date;
updatedDate?: Date;
}
class ExcelMappingService {
/**
*
* MD5
*/
generateColumnsHash(columns: string[]): string {
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
const sortedColumns = [...columns].sort();
const columnsString = sortedColumns.join("|");
return crypto.createHash("md5").update(columnsString).digest("hex");
}
/**
* 릿
*
*/
async findMappingByColumns(
tableName: string,
excelColumns: string[],
companyCode: string
): Promise<ExcelMappingTemplate | null> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 조회", {
tableName,
excelColumns,
hash,
companyCode,
});
const pool = getPool();
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
ORDER BY updated_date DESC
LIMIT 1
`;
params = [tableName, hash];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
AND (company_code = $3 OR company_code = '*')
ORDER BY
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
updated_date DESC
LIMIT 1
`;
params = [tableName, hash, companyCode];
}
const result = await pool.query(query, params);
if (result.rows.length > 0) {
logger.info("기존 매핑 템플릿 발견", {
id: result.rows[0].id,
tableName,
});
return result.rows[0];
}
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
return null;
} catch (error: any) {
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿 (UPSERT)
* ++ ,
*/
async saveMappingTemplate(
tableName: string,
excelColumns: string[],
columnMappings: Record<string, string | null>,
companyCode: string,
userId?: string
): Promise<ExcelMappingTemplate> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
tableName,
excelColumns,
hash,
columnMappings,
companyCode,
});
const pool = getPool();
const query = `
INSERT INTO excel_mapping_template (
table_name,
excel_columns,
excel_columns_hash,
column_mappings,
company_code,
created_date,
updated_date
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (table_name, excel_columns_hash, company_code)
DO UPDATE SET
column_mappings = EXCLUDED.column_mappings,
updated_date = NOW()
RETURNING
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
`;
const result = await pool.query(query, [
tableName,
excelColumns,
hash,
JSON.stringify(columnMappings),
companyCode,
]);
logger.info("매핑 템플릿 저장 완료", {
id: result.rows[0].id,
tableName,
hash,
});
return result.rows[0];
} catch (error: any) {
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async getMappingTemplates(
tableName: string,
companyCode: string
): Promise<ExcelMappingTemplate[]> {
try {
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
ORDER BY updated_date DESC
`;
params = [tableName];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND (company_code = $2 OR company_code = '*')
ORDER BY updated_date DESC
`;
params = [tableName, companyCode];
}
const result = await pool.query(query, params);
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
return result.rows;
} catch (error: any) {
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async deleteMappingTemplate(
id: number,
companyCode: string
): Promise<boolean> {
try {
logger.info("매핑 템플릿 삭제", { id, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
params = [id];
} else {
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
params = [id, companyCode];
}
const result = await pool.query(query, params);
if (result.rowCount && result.rowCount > 0) {
logger.info("매핑 템플릿 삭제 완료", { id });
return true;
}
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
return false;
} catch (error: any) {
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new ExcelMappingService();

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
const insertedData = { ...data }; const insertedData = { ...data };
console.log("🗺️ 필드 매핑 처리 중..."); console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
fields.push(mapping.targetField);
const value =
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
console.log( // 🔥 채번 규칙 서비스 동적 import
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` const { numberingRuleService } = await import("./numberingRuleService");
);
for (const mapping of fieldMappings) {
fields.push(mapping.targetField);
let value: any;
// 🔥 값 생성 유형에 따른 처리
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
// 자동 생성 (채번 규칙)
const companyCode = context.buttonContext?.companyCode || "*";
try {
value = await numberingRuleService.allocateCode(
mapping.numberingRuleId,
companyCode
);
console.log(
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
);
} catch (error: any) {
logger.error(`채번 규칙 적용 실패: ${error.message}`);
console.error(
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
);
throw new Error(
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
);
}
} else if (valueType === "static" || mapping.staticValue !== undefined) {
// 고정값
value = mapping.staticValue;
console.log(
` 📌 고정값: ${mapping.targetField} = ${value}`
);
} else {
// 소스 필드
value = data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
}
values.push(value); values.push(value);
// 🔥 삽입된 값을 데이터에 반영 // 🔥 삽입된 값을 데이터에 반영
insertedData[mapping.targetField] = value; insertedData[mapping.targetField] = value;
}); }
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
const hasWriterMapping = fieldMappings.some( const hasWriterMapping = fieldMappings.some(
@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
} }
}); });
// 🔑 Primary Key 자동 추가 (context-data 모드) // 🔑 Primary Key 자동 추가 여부 결정:
console.log("🔑 context-data 모드: Primary Key 자동 추가"); // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( // (사용자가 직접 조건을 설정한 경우 의도를 존중)
whereConditions, let finalWhereConditions: any[];
data, if (whereConditions && whereConditions.length > 0) {
targetTable console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
); finalWhereConditions = whereConditions;
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause( const whereResult = this.buildWhereClause(
enhancedWhereConditions, finalWhereConditions,
data, data,
paramIndex paramIndex
); );
@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
return deletedDataArray; return deletedDataArray;
} }
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가) // 🆕 context-data 모드: 개별 삭제
console.log("🎯 context-data 모드: 개별 삭제 시작"); console.log("🎯 context-data 모드: 개별 삭제 시작");
for (const data of dataArray) { for (const data of dataArray) {
console.log("🔍 WHERE 조건 처리 중..."); console.log("🔍 WHERE 조건 처리 중...");
// 🔑 Primary Key 자동 추가 (context-data 모드) // 🔑 Primary Key 자동 추가 여부 결정:
console.log("🔑 context-data 모드: Primary Key 자동 추가"); // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( // (사용자가 직접 조건을 설정한 경우 의도를 존중)
whereConditions, let finalWhereConditions: any[];
data, if (whereConditions && whereConditions.length > 0) {
targetTable console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
); finalWhereConditions = whereConditions;
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause( const whereResult = this.buildWhereClause(
enhancedWhereConditions, finalWhereConditions,
data, data,
1 1
); );
@ -2282,6 +2333,7 @@ export class NodeFlowExecutionService {
UPDATE ${targetTable} UPDATE ${targetTable}
SET ${setClauses.join(", ")} SET ${setClauses.join(", ")}
WHERE ${updateWhereConditions} WHERE ${updateWhereConditions}
RETURNING *
`; `;
logger.info(`🔄 UPDATE 실행:`, { logger.info(`🔄 UPDATE 실행:`, {
@ -2292,8 +2344,14 @@ export class NodeFlowExecutionService {
values: updateValues, values: updateValues,
}); });
await txClient.query(updateSql, updateValues); const updateResult = await txClient.query(updateSql, updateValues);
updatedCount++; updatedCount++;
// 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
if (updateResult.rows && updateResult.rows[0]) {
Object.assign(data, updateResult.rows[0]);
logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
}
} else { } else {
// 3-B. 없으면 INSERT // 3-B. 없으면 INSERT
const columns: string[] = []; const columns: string[] = [];
@ -2340,6 +2398,7 @@ export class NodeFlowExecutionService {
const insertSql = ` const insertSql = `
INSERT INTO ${targetTable} (${columns.join(", ")}) INSERT INTO ${targetTable} (${columns.join(", ")})
VALUES (${placeholders}) VALUES (${placeholders})
RETURNING *
`; `;
logger.info(` INSERT 실행:`, { logger.info(` INSERT 실행:`, {
@ -2348,8 +2407,14 @@ export class NodeFlowExecutionService {
conflictKeyValues, conflictKeyValues,
}); });
await txClient.query(insertSql, values); const insertResult = await txClient.query(insertSql, values);
insertedCount++; insertedCount++;
// 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
if (insertResult.rows && insertResult.rows[0]) {
Object.assign(data, insertResult.rows[0]);
logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
}
} }
} }
@ -2357,11 +2422,10 @@ export class NodeFlowExecutionService {
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}` `✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}`
); );
return { // 🔥 다음 노드에 전달할 데이터 반환
insertedCount, // dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
updatedCount, // 카운트 정보도 함께 반환하여 기존 호환성 유지
totalCount: insertedCount + updatedCount, return dataArray;
};
}; };
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
@ -2707,28 +2771,48 @@ export class NodeFlowExecutionService {
const trueData: any[] = []; const trueData: any[] = [];
const falseData: any[] = []; const falseData: any[] = [];
inputData.forEach((item: any) => { // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
const results = conditions.map((condition: any) => { for (const item of inputData) {
const results: boolean[] = [];
for (const condition of conditions) {
const fieldValue = item[condition.field]; const fieldValue = item[condition.field];
let compareValue = condition.value; // EXISTS 계열 연산자 처리
if (condition.valueType === "field") { if (
compareValue = item[condition.value]; condition.operator === "EXISTS_IN" ||
condition.operator === "NOT_EXISTS_IN"
) {
const existsResult = await this.evaluateExistsCondition(
fieldValue,
condition.operator,
condition.lookupTable,
condition.lookupField,
context.buttonContext?.companyCode
);
results.push(existsResult);
logger.info( logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
); );
} else { } else {
logger.info( // 일반 연산자 처리
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = item[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
results.push(
this.evaluateCondition(fieldValue, condition.operator, compareValue)
); );
} }
}
return this.evaluateCondition(
fieldValue,
condition.operator,
compareValue
);
});
const result = const result =
logic === "OR" logic === "OR"
@ -2740,7 +2824,7 @@ export class NodeFlowExecutionService {
} else { } else {
falseData.push(item); falseData.push(item);
} }
}); }
logger.info( logger.info(
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)` `🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
@ -2755,27 +2839,46 @@ export class NodeFlowExecutionService {
} }
// 단일 객체인 경우 // 단일 객체인 경우
const results = conditions.map((condition: any) => { const results: boolean[] = [];
for (const condition of conditions) {
const fieldValue = inputData[condition.field]; const fieldValue = inputData[condition.field];
let compareValue = condition.value; // EXISTS 계열 연산자 처리
if (condition.valueType === "field") { if (
compareValue = inputData[condition.value]; condition.operator === "EXISTS_IN" ||
condition.operator === "NOT_EXISTS_IN"
) {
const existsResult = await this.evaluateExistsCondition(
fieldValue,
condition.operator,
condition.lookupTable,
condition.lookupField,
context.buttonContext?.companyCode
);
results.push(existsResult);
logger.info( logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
); );
} else { } else {
logger.info( // 일반 연산자 처리
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = inputData[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
results.push(
this.evaluateCondition(fieldValue, condition.operator, compareValue)
); );
} }
}
return this.evaluateCondition(
fieldValue,
condition.operator,
compareValue
);
});
const result = const result =
logic === "OR" logic === "OR"
@ -2784,7 +2887,7 @@ export class NodeFlowExecutionService {
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`); logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 // 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
// 조건 결과를 저장하고, 원본 데이터는 항상 반환 // 조건 결과를 저장하고, 원본 데이터는 항상 반환
// 다음 노드에서 sourceHandle을 기반으로 필터링됨 // 다음 노드에서 sourceHandle을 기반으로 필터링됨
return { return {
@ -2795,6 +2898,69 @@ export class NodeFlowExecutionService {
}; };
} }
/**
* EXISTS_IN / NOT_EXISTS_IN
*
*/
private static async evaluateExistsCondition(
fieldValue: any,
operator: string,
lookupTable: string,
lookupField: string,
companyCode?: string
): Promise<boolean> {
if (!lookupTable || !lookupField) {
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
return false;
}
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
logger.info(
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
);
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
return false;
}
try {
// 멀티테넌시: company_code 필터 적용 여부 확인
// company_mng 테이블은 제외
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
let sql: string;
let params: any[];
if (hasCompanyCode) {
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
params = [fieldValue, companyCode];
} else {
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
params = [fieldValue];
}
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
const result = await query(sql, params);
const existsInTable = result[0]?.exists_result === true;
logger.info(
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}${existsInTable ? "존재함" : "존재하지 않음"}`
);
// EXISTS_IN: 존재하면 true
// NOT_EXISTS_IN: 존재하지 않으면 true
if (operator === "EXISTS_IN") {
return existsInTable;
} else {
return !existsInTable;
}
} catch (error: any) {
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
return false;
}
}
/** /**
* WHERE * WHERE
*/ */
@ -4280,6 +4446,8 @@ export class NodeFlowExecutionService {
/** /**
* *
* : (leftOperand operator rightOperand) additionalOperations
* : (width * height) / 1000000 * qty
*/ */
private static evaluateArithmetic( private static evaluateArithmetic(
arithmetic: any, arithmetic: any,
@ -4306,27 +4474,67 @@ export class NodeFlowExecutionService {
const leftNum = Number(left) || 0; const leftNum = Number(left) || 0;
const rightNum = Number(right) || 0; const rightNum = Number(right) || 0;
switch (arithmetic.operator) { // 기본 연산 수행
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
if (result === null) {
return null;
}
// 추가 연산 처리 (다중 연산 지원)
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
for (const addOp of arithmetic.additionalOperations) {
const operandValue = this.getOperandValue(
addOp.operand,
sourceRow,
targetRow,
resultValues
);
const operandNum = Number(operandValue) || 0;
result = this.applyOperator(result, addOp.operator, operandNum);
if (result === null) {
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
return null;
}
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
}
}
return result;
}
/**
*
*/
private static applyOperator(
left: number,
operator: string,
right: number
): number | null {
switch (operator) {
case "+": case "+":
return leftNum + rightNum; return left + right;
case "-": case "-":
return leftNum - rightNum; return left - right;
case "*": case "*":
return leftNum * rightNum; return left * right;
case "/": case "/":
if (rightNum === 0) { if (right === 0) {
logger.warn(`⚠️ 0으로 나누기 시도`); logger.warn(`⚠️ 0으로 나누기 시도`);
return null; return null;
} }
return leftNum / rightNum; return left / right;
case "%": case "%":
if (rightNum === 0) { if (right === 0) {
logger.warn(`⚠️ 0으로 나머지 연산 시도`); logger.warn(`⚠️ 0으로 나머지 연산 시도`);
return null; return null;
} }
return leftNum % rightNum; return left % right;
default: default:
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`); throw new Error(`지원하지 않는 연산자: ${operator}`);
} }
} }

View File

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

View File

@ -187,71 +187,68 @@ class TableCategoryValueService {
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
} }
// 2. 카테고리 값 조회 (형제 메뉴 포함) // 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
let query: string; let query: string;
let params: any[]; let params: any[];
const baseSelect = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회 // 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 if (menuObjid && siblingObjids.length > 0) {
query = ` query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
SELECT params = [tableName, columnName, siblingObjids];
value_id AS "valueId", logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
table_name AS "tableName", } else if (menuObjid) {
column_name AS "columnName", query = baseSelect + ` AND menu_objid = $3`;
value_code AS "valueCode", params = [tableName, columnName, menuObjid];
value_label AS "valueLabel", logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
value_order AS "valueOrder", } else {
parent_value_id AS "parentValueId", // menuObjid 없으면 모든 값 조회 (중복 가능)
depth, query = baseSelect;
description, params = [tableName, columnName];
color, logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
icon, }
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
} else { } else {
// 일반 회사: 자신의 카테고리 값만 조회 // 일반 회사: 자신의 회사 + menuObjid로 필터링
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 if (menuObjid && siblingObjids.length > 0) {
query = ` query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
SELECT params = [tableName, columnName, companyCode, siblingObjids];
value_id AS "valueId", logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
table_name AS "tableName", } else if (menuObjid) {
column_name AS "columnName", query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
value_code AS "valueCode", params = [tableName, columnName, companyCode, menuObjid];
value_label AS "valueLabel", logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
value_order AS "valueOrder", } else {
parent_value_id AS "parentValueId", // menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
depth, query = baseSelect + ` AND company_code = $3`;
description, params = [tableName, columnName, companyCode];
color, logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
icon, }
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
} }
if (!includeInactive) { if (!includeInactive) {
@ -1398,6 +1395,220 @@ class TableCategoryValueService {
throw error; throw error;
} }
} }
/**
* ( )
*
*
*
* @param tableName -
* @param companyCode -
* @returns { [columnName]: { [label]: code } }
*/
async getCategoryLabelToCodeMapping(
tableName: string,
companyCode: string
): Promise<Record<string, Record<string, string>>> {
try {
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
const pool = getPool();
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
const categoryColumnsQuery = `
SELECT column_name
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'category'
`;
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
if (categoryColumnsResult.rows.length === 0) {
logger.info("카테고리 타입 컬럼 없음", { tableName });
return {};
}
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
const result: Record<string, Record<string, string>> = {};
for (const columnName of categoryColumns) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
`;
params = [tableName, columnName];
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
AND (company_code = $3 OR company_code = '*')
`;
params = [tableName, columnName, companyCode];
}
const valuesResult = await pool.query(query, params);
// { [label]: code } 형태로 변환
const labelToCodeMap: Record<string, string> = {};
for (const row of valuesResult.rows) {
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
labelToCodeMap[row.value_label] = row.value_code;
// 소문자 키도 추가 (대소문자 무시 검색용)
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
}
if (Object.keys(labelToCodeMap).length > 0) {
result[columnName] = labelToCodeMap;
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
}
}
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
tableName,
columnCount: Object.keys(result).length
});
return result;
} catch (error: any) {
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
*
*
* DB
*
* @param tableName -
* @param companyCode -
* @param data -
* @returns
*/
async convertCategoryLabelsToCodesForData(
tableName: string,
companyCode: string,
data: Record<string, any>
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
try {
// 라벨→코드 매핑 조회
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
if (Object.keys(labelToCodeMapping).length === 0) {
// 카테고리 컬럼 없음
return { convertedData: data, conversions: [] };
}
const convertedData = { ...data };
const conversions: Array<{ column: string; label: string; code: string }> = [];
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
const value = data[columnName];
if (value !== undefined && value !== null && value !== "") {
const stringValue = String(value).trim();
// 다중 값 확인 (쉼표로 구분된 경우)
if (stringValue.includes(",")) {
// 다중 카테고리 값 처리
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
const convertedCodes: string[] = [];
let allConverted = true;
for (const label of labels) {
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[label];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[label.toLowerCase()];
}
if (matchedCode) {
convertedCodes.push(matchedCode);
conversions.push({
column: columnName,
label: label,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
if (isAlreadyCode) {
// 이미 코드값이면 그대로 사용
convertedCodes.push(label);
} else {
// 라벨도 코드도 아니면 원래 값 유지
convertedCodes.push(label);
allConverted = false;
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
}
}
}
// 변환된 코드들을 쉼표로 합쳐서 저장
convertedData[columnName] = convertedCodes.join(",");
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
} else {
// 단일 값 처리
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[stringValue];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[stringValue.toLowerCase()];
}
if (matchedCode) {
// 라벨 값을 코드 값으로 변환
convertedData[columnName] = matchedCode;
conversions.push({
column: columnName,
label: stringValue,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인 (역방향 확인)
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
if (!isAlreadyCode) {
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
}
// 변환 없이 원래 값 유지
}
}
}
}
logger.info(`카테고리 라벨→코드 변환 완료`, {
tableName,
conversionCount: conversions.length,
conversions,
});
return { convertedData, conversions };
} catch (error: any) {
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
// 실패 시 원본 데이터 반환
return { convertedData: data, conversions: [] };
}
}
} }
export default new TableCategoryValueService(); export default new TableCategoryValueService();

View File

@ -1306,6 +1306,48 @@ export class TableManagementService {
paramCount: number; paramCount: number;
} | null> { } | null> {
try { try {
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
if (Array.isArray(value) && value.length > 0) {
// 배열의 각 값에 대해 OR 조건으로 검색
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
// 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = [];
const values: any[] = [];
value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
// - 정확히 "2"
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + idx * 4;
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(
safeValue,
`${safeValue},%`,
`%,${safeValue}`,
`%,${safeValue},%`
);
});
logger.info(
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
paramCount: values.length,
};
}
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) { if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo( const columnInfo = await this.getColumnWebTypeInfo(
@ -1740,18 +1782,26 @@ export class TableManagementService {
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직) // displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn; let displayColumn = entityTypeInfo.displayColumn;
if (!displayColumn || displayColumn === "none" || displayColumn === "") { if (
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn); !displayColumn ||
displayColumn === "none" ||
displayColumn === ""
) {
displayColumn = await this.findDisplayColumnForTable(
referenceTable,
referenceColumn
);
logger.info( logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}` `🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
); );
} }
// 참조 테이블의 표시 컬럼으로 검색 // 참조 테이블의 표시 컬럼으로 검색
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
return { return {
whereClause: `EXISTS ( whereClause: `EXISTS (
SELECT 1 FROM ${referenceTable} ref SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = ${columnName} WHERE ref.${referenceColumn} = main.${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex} AND ref.${displayColumn} ILIKE $${paramIndex}
)`, )`,
values: [`%${value}%`], values: [`%${value}%`],
@ -2115,14 +2165,14 @@ export class TableManagementService {
// 안전한 테이블명 검증 // 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// 전체 개수 조회 // 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
const countResult = await query<any>(countQuery, searchValues); const countResult = await query<any>(countQuery, searchValues);
const total = parseInt(countResult[0].count); const total = parseInt(countResult[0].count);
// 데이터 조회 // 데이터 조회 (main 별칭 추가)
const dataQuery = ` const dataQuery = `
SELECT * FROM ${safeTableName} SELECT main.* FROM ${safeTableName} main
${whereClause} ${whereClause}
${orderClause} ${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@ -2261,11 +2311,12 @@ export class TableManagementService {
/** /**
* *
* @returns ()
*/ */
async addTableData( async addTableData(
tableName: string, tableName: string,
data: Record<string, any> data: Record<string, any>
): Promise<void> { ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
try { try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data); logger.info(`추가할 데이터:`, data);
@ -2296,10 +2347,41 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`); logger.info(`created_date 자동 추가: ${data.created_date}`);
} }
// 컬럼명과 값을 분리하고 타입에 맞게 변환 // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const columns = Object.keys(data); const skippedColumns: string[] = [];
const values = Object.values(data).map((value, index) => { const existingColumns = Object.keys(data).filter((col) => {
const columnName = columns[index]; const exists = columnTypeMap.has(col);
if (!exists) {
skippedColumns.push(col);
}
return exists;
});
// 무시된 컬럼이 있으면 경고 로그 출력
if (skippedColumns.length > 0) {
logger.warn(
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
);
logger.warn(
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
skippedColumns.map((col) => ({ column: col, value: data[col] }))
);
}
if (existingColumns.length === 0) {
throw new Error(
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
);
}
logger.info(
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
);
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
const columns = existingColumns;
const values = columns.map((columnName) => {
const value = data[columnName];
const dataType = columnTypeMap.get(columnName) || "text"; const dataType = columnTypeMap.get(columnName) || "text";
const convertedValue = this.convertValueForPostgreSQL(value, dataType); const convertedValue = this.convertValueForPostgreSQL(value, dataType);
logger.info( logger.info(
@ -2355,6 +2437,12 @@ export class TableManagementService {
await query(insertQuery, values); await query(insertQuery, values);
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}`);
// 무시된 컬럼과 저장된 컬럼 정보 반환
return {
skippedColumns,
savedColumns: existingColumns,
};
} catch (error) { } catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
throw error; throw error;
@ -2409,11 +2497,19 @@ export class TableManagementService {
} }
// SET 절 생성 (수정할 데이터) - 먼저 생성 // SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = []; const setConditions: string[] = [];
const setValues: any[] = []; const setValues: any[] = [];
let paramIndex = 1; let paramIndex = 1;
const skippedColumns: string[] = [];
Object.keys(updatedData).forEach((column) => { Object.keys(updatedData).forEach((column) => {
// 테이블에 존재하지 않는 컬럼은 스킵
if (!columnTypeMap.has(column)) {
skippedColumns.push(column);
return;
}
const dataType = columnTypeMap.get(column) || "text"; const dataType = columnTypeMap.get(column) || "text";
setConditions.push( setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
@ -2424,6 +2520,12 @@ export class TableManagementService {
paramIndex++; paramIndex++;
}); });
if (skippedColumns.length > 0) {
logger.info(
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
);
}
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
let whereConditions: string[] = []; let whereConditions: string[] = [];
let whereValues: any[] = []; let whereValues: any[] = [];
@ -2626,6 +2728,12 @@ export class TableManagementService {
filterColumn?: string; filterColumn?: string;
filterValue?: any; filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
} }
): Promise<EntityJoinResponse> { ): Promise<EntityJoinResponse> {
const startTime = Date.now(); const startTime = Date.now();
@ -2676,33 +2784,74 @@ export class TableManagementService {
); );
for (const additionalColumn of options.additionalJoinColumns) { for (const additionalColumn of options.additionalJoinColumns) {
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기) // 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
const baseJoinConfig = joinConfigs.find( let baseJoinConfig = joinConfigs.find(
(config) => config.sourceColumn === additionalColumn.sourceColumn (config) => config.sourceColumn === additionalColumn.sourceColumn
); );
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
baseJoinConfig = joinConfigs.find(
(config) =>
config.referenceTable ===
(additionalColumn as any).referenceTable
);
if (baseJoinConfig) {
logger.info(
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`
);
}
}
if (baseJoinConfig) { if (baseJoinConfig) {
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name) // joinAlias에서 실제 컬럼명 추출
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명 const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name // 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
let actualColumnName: string;
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${frontendSourceColumn}_`,
""
);
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
// 실제 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${sourceColumn}_`,
""
);
} else {
// 어느 것도 아니면 원본 사용
actualColumnName = originalJoinAlias;
}
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
logger.info(`🔍 조인 컬럼 상세 분석:`, { logger.info(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn, sourceColumn,
joinAlias, frontendSourceColumn,
originalJoinAlias,
correctedJoinAlias,
actualColumnName, actualColumnName,
referenceTable: additionalColumn.sourceTable, referenceTable: (additionalColumn as any).referenceTable,
}); });
// 🚨 기본 Entity 조인과 중복되지 않도록 체크 // 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin = const isBasicEntityJoin =
additionalColumn.joinAlias === correctedJoinAlias === `${sourceColumn}_name`;
`${baseJoinConfig.sourceColumn}_name`;
if (isBasicEntityJoin) { if (isBasicEntityJoin) {
logger.info( logger.info(
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀` `⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
); );
continue; // 기본 Entity 조인과 중복되면 추가하지 않음 continue; // 기본 Entity 조인과 중복되면 추가하지 않음
} }
@ -2710,14 +2859,14 @@ export class TableManagementService {
// 추가 조인 컬럼 설정 생성 // 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = { const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName, sourceTable: tableName,
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code) sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable: referenceTable:
(additionalColumn as any).referenceTable || (additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info) baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code) referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name) displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성 displayColumn: actualColumnName, // 하위 호환성
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name) aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자 separator: " - ", // 기본 구분자
}; };
@ -3077,8 +3226,10 @@ export class TableManagementService {
} }
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함) // Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
const allEntityColumns = [ const allEntityColumns = [
...joinConfigs.map((config) => config.aliasColumn), ...joinConfigs.map((config) => config.aliasColumn),
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등) // 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
...joinConfigs.flatMap((config) => { ...joinConfigs.flatMap((config) => {
const additionalColumns = []; const additionalColumns = [];
@ -3484,8 +3635,10 @@ export class TableManagementService {
}); });
// main. 접두사 추가 (조인 쿼리용) // main. 접두사 추가 (조인 쿼리용)
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
condition = condition.replace( condition = condition.replace(
new RegExp(`\\b${columnName}\\b`, "g"), new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
`main.${columnName}` `main.${columnName}`
); );
conditions.push(condition); conditions.push(condition);
@ -3684,6 +3837,18 @@ export class TableManagementService {
const cacheableJoins: EntityJoinConfig[] = []; const cacheableJoins: EntityJoinConfig[] = [];
const dbJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = [];
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
const companySpecificTables = [
"supplier_mng",
"customer_mng",
"item_info",
"dept_info",
"sales_order_mng", // 🔧 수주관리 테이블 추가
"sales_order_detail", // 🔧 수주상세 테이블 추가
"partner_info", // 🔧 거래처 테이블 추가
// 필요시 추가
];
for (const config of joinConfigs) { for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") { if (config.referenceTable === "table_column_category_values") {
@ -3692,6 +3857,13 @@ export class TableManagementService {
continue; continue;
} }
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
if (companySpecificTables.includes(config.referenceTable)) {
dbJoins.push(config);
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
continue;
}
// 캐시 가능성 확인 // 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference( const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable, config.referenceTable,
@ -3930,9 +4102,10 @@ export class TableManagementService {
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}` `컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
); );
// table_type_columns에서 입력타입 정보 조회 (company_code 필터링) // table_type_columns에서 입력타입 정보 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
const rawInputTypes = await query<any>( const rawInputTypes = await query<any>(
`SELECT `SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName", ttc.column_name as "columnName",
COALESCE(cl.column_label, ttc.column_name) as "displayName", COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType", ttc.input_type as "inputType",
@ -3946,8 +4119,10 @@ export class TableManagementService {
LEFT JOIN information_schema.columns ic LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = $1 WHERE ttc.table_name = $1
AND ttc.company_code = $2 AND ttc.company_code IN ($2, '*')
ORDER BY ttc.display_order, ttc.column_name`, ORDER BY ttc.column_name,
CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END,
ttc.display_order`,
[tableName, companyCode] [tableName, companyCode]
); );
@ -3961,17 +4136,20 @@ export class TableManagementService {
const mappingTableExists = tableExistsResult[0]?.table_exists === true; const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회 // 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
let categoryMappings: Map<string, number[]> = new Map(); let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) { if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>( const mappings = await query<any>(
`SELECT `SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName", logical_column_name as "columnName",
menu_objid as "menuObjid" menu_objid as "menuObjid"
FROM category_column_mapping FROM category_column_mapping
WHERE table_name = $1 WHERE table_name = $1
AND company_code = $2`, AND company_code IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode] [tableName, companyCode]
); );
@ -4574,4 +4752,110 @@ export class TableManagementService {
return false; return false;
} }
} }
/**
*
* column_labels에서 .
*
* @param leftTable
* @param rightTable
* @returns
*/
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
> {
try {
logger.info(
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
);
const relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}> = [];
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
const rightToLeftRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[rightTable, leftTable]
);
for (const rel of rightToLeftRels) {
relations.push({
leftColumn: rel.reference_column,
rightColumn: rel.column_name,
direction: "right_to_left",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: left_table의 item_id -> right_table(item_info)의 item_number
const leftToRightRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[leftTable, rightTable]
);
for (const rel of leftToRightRels) {
relations.push({
leftColumn: rel.column_name,
rightColumn: rel.reference_column,
direction: "left_to_right",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
);
});
return relations;
} catch (error) {
logger.error(
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
error
);
return [];
}
}
} }

View File

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

View File

@ -587,3 +587,5 @@ const result = await executeNodeFlow(flowId, {

View File

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

View File

@ -360,3 +360,5 @@

View File

@ -346,3 +346,5 @@ const getComponentValue = (componentId: string) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@ export default function DraftsPage() {
content: draft.htmlContent, content: draft.htmlContent,
accountId: draft.accountId, accountId: draft.accountId,
}); });
router.push(`/admin/mail/send?${params.toString()}`); router.push(`/admin/automaticMng/mail/send?${params.toString()}`);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {

View File

@ -1056,7 +1056,7 @@ ${data.originalBody}`;
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push(`/admin/mail/templates`)} onClick={() => router.push(`/admin/automaticMng/mail/templates`)}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />

View File

@ -336,7 +336,7 @@ export default function SentMailPage() {
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
</Button> </Button>
<Button onClick={() => router.push("/admin/mail/send")} size="sm"> <Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
<Mail className="w-4 h-4 mr-2" /> <Mail className="w-4 h-4 mr-2" />
</Button> </Button>

View File

@ -1,12 +0,0 @@
"use client";
import { useParams } from "next/navigation";
import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
export default function DepartmentManagementPage() {
const params = useParams();
const companyCode = params.companyCode as string;
return <DepartmentManagement companyCode={companyCode} />;
}

View File

@ -1,25 +0,0 @@
import { CompanyManagement } from "@/components/admin/CompanyManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
*/
export default function CompanyPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<CompanyManagement />
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,449 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
/**
*
* - CSR
* -
* - ///
*/
export default function DashboardListClient() {
const router = useRouter();
const { toast } = useToast();
// 상태 관리
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalCount, setTotalCount] = useState(0);
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
setLoading(true);
setError(null);
const result = await dashboardApi.getMyDashboards({
search: searchTerm,
page: currentPage,
limit: pageSize,
});
setDashboards(result.dashboards);
setTotalCount(result.pagination.total);
} catch (err) {
console.error("Failed to load dashboards:", err);
setError(
err instanceof Error
? err.message
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
);
} finally {
setLoading(false);
}
};
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
useEffect(() => {
loadDashboards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, currentPage, pageSize]);
// 페이지네이션 정보 계산
const paginationInfo: PaginationInfo = {
currentPage,
totalPages: Math.ceil(totalCount / pageSize) || 1,
totalItems: totalCount,
itemsPerPage: pageSize,
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
endItem: Math.min(currentPage * pageSize, totalCount),
};
// 페이지 변경 핸들러
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
};
// 대시보드 삭제 확인 모달 열기
const handleDeleteClick = (id: string, title: string) => {
setDeleteTarget({ id, title });
setDeleteDialogOpen(true);
};
// 대시보드 삭제 실행
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await dashboardApi.deleteDashboard(deleteTarget.id);
setDeleteDialogOpen(false);
setDeleteTarget(null);
toast({
title: "성공",
description: "대시보드가 삭제되었습니다.",
});
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
setDeleteDialogOpen(false);
toast({
title: "오류",
description: "대시보드 삭제에 실패했습니다.",
variant: "destructive",
});
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
await dashboardApi.createDashboard({
title: `${fullDashboard.title} (복사본)`,
description: fullDashboard.description,
elements: fullDashboard.elements || [],
isPublic: false,
tags: fullDashboard.tags,
category: fullDashboard.category,
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
});
toast({
title: "성공",
description: "대시보드가 복사되었습니다.",
});
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
toast({
title: "오류",
description: "대시보드 복사에 실패했습니다.",
variant: "destructive",
});
}
};
// 포맷팅 헬퍼
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
};
return (
<>
{/* 검색 및 액션 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<div className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span>
</div>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 대시보드 목록 */}
{loading ? (
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="bg-card hidden shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16 text-right">
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
</div>
))}
</div>
</div>
))}
</div>
</>
) : error ? (
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
<div className="flex flex-col items-center gap-4 text-center">
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
<AlertCircle className="text-destructive h-8 w-8" />
</div>
<div>
<h3 className="text-destructive mb-2 text-lg font-semibold"> </h3>
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
</div>
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
) : dashboards.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">
<button
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
</button>
</TableCell>
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{dashboard.createdByName || dashboard.createdBy || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.updatedAt)}
</TableCell>
<TableCell className="h-16 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="text-destructive focus:text-destructive gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{dashboards.map((dashboard) => (
<div
key={dashboard.id}
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<button
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
<h3 className="text-base font-semibold">{dashboard.title}</h3>
</button>
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
</div>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => handleCopy(dashboard)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</>
)}
{/* 페이지네이션 */}
{!loading && dashboards.length > 0 && (
<Pagination
paginationInfo={paginationInfo}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showPageSizeSelector={true}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
{/* 삭제 확인 모달 */}
<DeleteConfirmModal
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="대시보드 삭제"
description={
<>
&quot;{deleteTarget?.title}&quot; ?
<br /> .
</>
}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -1,23 +0,0 @@
"use client";
import React from "react";
import { use } from "react";
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
interface PageProps {
params: Promise<{ id: string }>;
}
/**
*
* -
*/
export default function DashboardEditPage({ params }: PageProps) {
const { id } = use(params);
return (
<div className="h-full">
<DashboardDesigner dashboardId={id} />
</div>
);
}

View File

@ -1,12 +0,0 @@
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
/**
*
*/
export default function DashboardNewPage() {
return (
<div className="h-full">
<DashboardDesigner />
</div>
);
}

View File

@ -1,23 +0,0 @@
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
/**
*
* -
* - CSR로
*/
export default function DashboardListPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 클라이언트 컴포넌트 */}
<DashboardListClient />
</div>
</div>
);
}

View File

@ -1,14 +0,0 @@
"use client";
import MultiLang from "@/components/admin/MultiLang";
export default function I18nPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8">
<MultiLang />
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,124 @@
"use client"; "use client";
import React from "react"; import React, { useState, useEffect } from "react";
import MonitoringDashboard from "@/components/admin/MonitoringDashboard"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
import { toast } from "sonner";
import { BatchAPI, BatchMonitoring } from "@/lib/api/batch";
export default function MonitoringPage() { export default function MonitoringPage() {
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
useEffect(() => {
loadMonitoringData();
let interval: NodeJS.Timeout;
if (autoRefresh) {
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
}
return () => {
if (interval) clearInterval(interval);
};
}, [autoRefresh]);
const loadMonitoringData = async () => {
setIsLoading(true);
try {
const data = await BatchAPI.getBatchMonitoring();
setMonitoring(data);
} catch (error) {
console.error("모니터링 데이터 조회 오류:", error);
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
const handleRefresh = () => {
loadMonitoringData();
};
const toggleAutoRefresh = () => {
setAutoRefresh(!autoRefresh);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'running':
return <Play className="h-4 w-4 text-blue-500" />;
case 'pending':
return <Clock className="h-4 w-4 text-yellow-500" />;
default:
return <Clock className="h-4 w-4 text-gray-500" />;
}
};
const getStatusBadge = (status: string) => {
const variants = {
completed: "bg-green-100 text-green-800",
failed: "bg-destructive/20 text-red-800",
running: "bg-primary/20 text-blue-800",
pending: "bg-yellow-100 text-yellow-800",
cancelled: "bg-gray-100 text-gray-800",
};
const labels = {
completed: "완료",
failed: "실패",
running: "실행 중",
pending: "대기 중",
cancelled: "취소됨",
};
return (
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
{labels[status as keyof typeof labels] || status}
</Badge>
);
};
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
};
const getSuccessRate = () => {
if (!monitoring) return 0;
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
if (total === 0) return 100;
return Math.round((monitoring.successful_jobs_today / total) * 100);
};
if (!monitoring) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
<p> ...</p>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none px-4 py-8 space-y-8">
@ -16,7 +131,170 @@ export default function MonitoringPage() {
</div> </div>
{/* 모니터링 대시보드 */} {/* 모니터링 대시보드 */}
<MonitoringDashboard /> <div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold"> </h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleAutoRefresh}
className={autoRefresh ? "bg-accent text-primary" : ""}
>
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<div className="text-2xl">📋</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
<p className="text-xs text-muted-foreground">
: {monitoring.active_jobs}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<div className="text-2xl">🔄</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
<p className="text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<div className="text-2xl"></div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
<p className="text-xs text-muted-foreground">
: {getSuccessRate()}%
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<div className="text-2xl"></div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
<p className="text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
</div>
{/* 성공률 진행바 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>: {monitoring.successful_jobs_today}</span>
<span>: {monitoring.failed_jobs_today}</span>
</div>
<Progress value={getSuccessRate()} className="h-2" />
<div className="text-center text-sm text-muted-foreground">
{getSuccessRate()}%
</div>
</div>
</CardContent>
</Card>
{/* 최근 실행 이력 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
{monitoring.recent_executions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> ID</TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monitoring.recent_executions.map((execution) => (
<TableRow key={execution.id}>
<TableCell>
<div className="flex items-center gap-2">
{getStatusIcon(execution.execution_status)}
{getStatusBadge(execution.execution_status)}
</div>
</TableCell>
<TableCell className="font-mono">#{execution.job_id}</TableCell>
<TableCell>
{execution.started_at
? new Date(execution.started_at).toLocaleString()
: "-"}
</TableCell>
<TableCell>
{execution.completed_at
? new Date(execution.completed_at).toLocaleString()
: "-"}
</TableCell>
<TableCell>
{execution.execution_time_ms
? formatDuration(execution.execution_time_ms)
: "-"}
</TableCell>
<TableCell className="max-w-xs">
{execution.error_message ? (
<span className="text-destructive text-sm truncate block">
{execution.error_message}
</span>
) : (
"-"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react"; import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer"; import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@ -9,6 +9,7 @@ export default function AdminPage() {
return ( return (
<div className="bg-background min-h-screen"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16"> <div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
{/* 주요 관리 기능 */} {/* 주요 관리 기능 */}
<div className="mx-auto max-w-7xl space-y-10"> <div className="mx-auto max-w-7xl space-y-10">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
@ -168,7 +169,7 @@ export default function AdminPage() {
</div> </div>
</Link> </Link>
<Link href="/admin/external-connections" className="block"> <Link href="/admin/automaticMng/exconList" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors"> <div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg"> <div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
@ -182,7 +183,7 @@ export default function AdminPage() {
</div> </div>
</Link> </Link>
<Link href="/admin/commonCode" className="block"> <Link href="/admin/systemMng/commonCodeList" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors"> <div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg"> <div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">

View File

@ -1,30 +0,0 @@
"use client";
import { use } from "react";
import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/roles/[id]
*
* :
* - (Dual List Box)
* - (CRUD )
*/
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
const { id } = use(params);
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 메인 컨텐츠 */}
<RoleDetailManagement roleId={id} />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -1,39 +0,0 @@
"use client";
import { RoleManagement } from "@/components/admin/RoleManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/roles
*
* shadcn/ui
*
* :
* -
* - //
* - (Dual List Box)
* - (CRUD )
*/
export default function RolesPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">
( )
</p>
</div>
{/* 메인 컨텐츠 */}
<RoleManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -1,17 +1,18 @@
"use client"; "use client";
import React, { useState, useRef, useCallback } from "react"; import React, { useState, useRef, useCallback } from "react";
import { use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu"; import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
import { WidgetConfigSidebar } from "./WidgetConfigSidebar"; import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext"; import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -32,18 +33,24 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
interface DashboardDesignerProps {
dashboardId?: string;
}
/** /**
* * /
* URL: /admin/screenMng/dashboardList/[id]
* - id가 "new"
* - id가
*
* :
* - / * - /
* - (12 ) * - (12 )
* - , , * - , ,
* - / * - /
*/ */
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) { export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) {
const { id: paramId } = use(params);
// "new"면 생성 모드, 아니면 편집 모드
const initialDashboardId = paramId === "new" ? undefined : paramId;
const router = useRouter(); const router = useRouter();
const { refreshMenus } = useMenu(); const { refreshMenus } = useMenu();
const [elements, setElements] = useState<DashboardElement[]>([]); const [elements, setElements] = useState<DashboardElement[]>([]);
@ -643,7 +650,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
open={successModalOpen} open={successModalOpen}
onOpenChange={() => { onOpenChange={() => {
setSuccessModalOpen(false); setSuccessModalOpen(false);
router.push("/admin/dashboard"); router.push("/admin/screenMng/dashboardList");
}} }}
> >
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@ -660,7 +667,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
<Button <Button
onClick={() => { onClick={() => {
setSuccessModalOpen(false); setSuccessModalOpen(false);
router.push("/admin/dashboard"); router.push("/admin/screenMng/dashboardList");
}} }}
> >

View File

@ -0,0 +1,458 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
/**
*
* - CSR
* -
* - ///
*/
export default function DashboardListPage() {
const router = useRouter();
const { toast } = useToast();
// 상태 관리
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalCount, setTotalCount] = useState(0);
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
setLoading(true);
setError(null);
const result = await dashboardApi.getMyDashboards({
search: searchTerm,
page: currentPage,
limit: pageSize,
});
setDashboards(result.dashboards);
setTotalCount(result.pagination.total);
} catch (err) {
console.error("Failed to load dashboards:", err);
setError(
err instanceof Error
? err.message
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
);
} finally {
setLoading(false);
}
};
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
useEffect(() => {
loadDashboards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, currentPage, pageSize]);
// 페이지네이션 정보 계산
const paginationInfo: PaginationInfo = {
currentPage,
totalPages: Math.ceil(totalCount / pageSize) || 1,
totalItems: totalCount,
itemsPerPage: pageSize,
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
endItem: Math.min(currentPage * pageSize, totalCount),
};
// 페이지 변경 핸들러
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
};
// 대시보드 삭제 확인 모달 열기
const handleDeleteClick = (id: string, title: string) => {
setDeleteTarget({ id, title });
setDeleteDialogOpen(true);
};
// 대시보드 삭제 실행
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await dashboardApi.deleteDashboard(deleteTarget.id);
setDeleteDialogOpen(false);
setDeleteTarget(null);
toast({
title: "성공",
description: "대시보드가 삭제되었습니다.",
});
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
setDeleteDialogOpen(false);
toast({
title: "오류",
description: "대시보드 삭제에 실패했습니다.",
variant: "destructive",
});
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
await dashboardApi.createDashboard({
title: `${fullDashboard.title} (복사본)`,
description: fullDashboard.description,
elements: fullDashboard.elements || [],
isPublic: false,
tags: fullDashboard.tags,
category: fullDashboard.category,
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
});
toast({
title: "성공",
description: "대시보드가 복사되었습니다.",
});
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
toast({
title: "오류",
description: "대시보드 복사에 실패했습니다.",
variant: "destructive",
});
}
};
// 포맷팅 헬퍼
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
};
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 검색 및 액션 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<div className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span>
</div>
</div>
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 대시보드 목록 */}
{loading ? (
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="bg-card hidden shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16 text-right">
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
</div>
))}
</div>
</div>
))}
</div>
</>
) : error ? (
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
<div className="flex flex-col items-center gap-4 text-center">
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
<AlertCircle className="text-destructive h-8 w-8" />
</div>
<div>
<h3 className="text-destructive mb-2 text-lg font-semibold"> </h3>
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
</div>
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
) : dashboards.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">
<button
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
</button>
</TableCell>
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{dashboard.createdByName || dashboard.createdBy || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.updatedAt)}
</TableCell>
<TableCell className="h-16 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="text-destructive focus:text-destructive gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{dashboards.map((dashboard) => (
<div
key={dashboard.id}
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<button
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
<h3 className="text-base font-semibold">{dashboard.title}</h3>
</button>
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
</div>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => handleCopy(dashboard)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</>
)}
{/* 페이지네이션 */}
{!loading && dashboards.length > 0 && (
<Pagination
paginationInfo={paginationInfo}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showPageSizeSelector={true}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
{/* 삭제 확인 모달 */}
<DeleteConfirmModal
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="대시보드 삭제"
description={
<>
&quot;{deleteTarget?.title}&quot; ?
<br /> .
</>
}
onConfirm={handleDeleteConfirm}
/>
</div>
</div>
);
}

View File

@ -1,129 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template";
export default function ScreenManagementPage() {
const [currentStep, setCurrentStep] = useState<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
// 화면 설계 모드일 때는 전체 화면 사용
const isDesignMode = currentStep === "design";
// 단계별 제목과 설명
const stepConfig = {
list: {
title: "화면 목록 관리",
description: "생성된 화면들을 확인하고 관리하세요",
},
design: {
title: "화면 설계",
description: "드래그앤드롭으로 화면을 설계하세요",
},
template: {
title: "템플릿 관리",
description: "화면 템플릿을 관리하고 재사용하세요",
},
};
// 다음 단계로 이동
const goToNextStep = (nextStep: Step) => {
setStepHistory((prev) => [...prev, nextStep]);
setCurrentStep(nextStep);
};
// 이전 단계로 이동
const goToPreviousStep = () => {
if (stepHistory.length > 1) {
const newHistory = stepHistory.slice(0, -1);
const previousStep = newHistory[newHistory.length - 1];
setStepHistory(newHistory);
setCurrentStep(previousStep);
}
};
// 특정 단계로 이동
const goToStep = (step: Step) => {
setCurrentStep(step);
// 해당 단계까지의 히스토리만 유지
const stepIndex = stepHistory.findIndex((s) => s === step);
if (stepIndex !== -1) {
setStepHistory(stepHistory.slice(0, stepIndex + 1));
}
};
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
if (isDesignMode) {
return (
<div className="fixed inset-0 z-50 bg-background">
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> 릿 </p>
</div>
{/* 단계별 내용 */}
<div className="flex-1">
{/* 화면 목록 단계 */}
{currentStep === "list" && (
<ScreenList
onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
onDesignScreen={(screen) => {
setSelectedScreen(screen);
goToNextStep("design");
}}
/>
)}
{/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button
variant="outline"
onClick={goToPreviousStep}
className="h-10 gap-2 text-sm font-medium"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
onClick={() => goToStep("list")}
className="h-10 gap-2 text-sm font-medium"
>
</Button>
</div>
</div>
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
)}
</div>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -37,7 +37,7 @@ export default function ReportDesignerPage() {
description: "리포트를 찾을 수 없습니다.", description: "리포트를 찾을 수 없습니다.",
variant: "destructive", variant: "destructive",
}); });
router.push("/admin/report"); router.push("/admin/screenMng/reportList");
} }
} catch (error: any) { } catch (error: any) {
toast({ toast({
@ -45,7 +45,7 @@ export default function ReportDesignerPage() {
description: error.message || "리포트를 불러오는데 실패했습니다.", description: error.message || "리포트를 불러오는데 실패했습니다.",
variant: "destructive", variant: "destructive",
}); });
router.push("/admin/report"); router.push("/admin/screenMng/reportList");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -26,7 +26,7 @@ export default function ReportManagementPage() {
const handleCreateNew = () => { const handleCreateNew = () => {
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입 // 새 리포트는 'new'라는 특수 ID로 디자이너 진입
router.push("/admin/report/designer/new"); router.push("/admin/screenMng/reportList/designer/new");
}; };
return ( return (

View File

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

View File

@ -13,7 +13,7 @@ export default function NodeEditorPage() {
useEffect(() => { useEffect(() => {
// /admin/dataflow 메인 페이지로 리다이렉트 // /admin/dataflow 메인 페이지로 리다이렉트
router.replace("/admin/dataflow"); router.replace("/admin/systemMng/dataflow");
}, [router]); }, [router]);
return ( return (

View File

@ -51,17 +51,17 @@ export default function DataFlowPage() {
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용 // 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) { if (isEditorMode) {
return ( return (
<div className="fixed inset-0 z-50 bg-background"> <div className="bg-background fixed inset-0 z-50">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 에디터 헤더 */} {/* 에디터 헤더 */}
<div className="flex items-center gap-4 border-b bg-background p-4"> <div className="bg-background flex items-center gap-4 border-b p-4">
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2"> <Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> </h1> <h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="text-muted-foreground mt-1 text-sm">
</p> </p>
</div> </div>
@ -77,12 +77,12 @@ export default function DataFlowPage() {
} }
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-4 sm:p-6"> <div className="space-y-6 p-4 sm: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-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
{/* 플로우 목록 */} {/* 플로우 목록 */}

View File

@ -7,13 +7,19 @@ 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 { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react";
import { DataTable } from "@/components/common/DataTable"; import { DataTable } from "@/components/common/DataTable";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import LangKeyModal from "./LangKeyModal"; import LangKeyModal from "@/components/admin/LangKeyModal";
import LanguageModal from "./LanguageModal"; import LanguageModal from "@/components/admin/LanguageModal";
import { CategoryTree } from "@/components/admin/multilang/CategoryTree";
import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { LangCategory } from "@/lib/api/multilang";
interface Language { interface Language {
langCode: string; langCode: string;
@ -29,6 +35,7 @@ interface LangKey {
langKey: string; langKey: string;
description: string; description: string;
isActive: string; isActive: string;
categoryId?: number;
} }
interface LangText { interface LangText {
@ -39,7 +46,7 @@ interface LangText {
isActive: string; isActive: string;
} }
export default function MultiLangPage() { export default function I18nPage() {
const { user } = useAuth(); const { user } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [languages, setLanguages] = useState<Language[]>([]); const [languages, setLanguages] = useState<Language[]>([]);
@ -59,25 +66,23 @@ export default function MultiLangPage() {
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set()); const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys"); const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
// 카테고리 관련 상태
const [selectedCategory, setSelectedCategory] = useState<LangCategory | null>(null);
const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]); const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
// 회사 목록 조회 // 회사 목록 조회
const fetchCompanies = async () => { const fetchCompanies = async () => {
try { try {
// console.log("회사 목록 조회 시작");
const response = await apiClient.get("/admin/companies"); const response = await apiClient.get("/admin/companies");
// console.log("회사 목록 응답 데이터:", response.data);
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
const companyList = data.data.map((company: any) => ({ const companyList = data.data.map((company: any) => ({
code: company.company_code, code: company.company_code,
name: company.company_name, name: company.company_name,
})); }));
// console.log("변환된 회사 목록:", companyList);
setCompanies(companyList); setCompanies(companyList);
} else {
// console.error("회사 목록 조회 실패:", data.message);
} }
} catch (error) { } catch (error) {
// console.error("회사 목록 조회 실패:", error); // console.error("회사 목록 조회 실패:", error);
@ -98,22 +103,24 @@ export default function MultiLangPage() {
}; };
// 다국어 키 목록 조회 // 다국어 키 목록 조회
const fetchLangKeys = async () => { const fetchLangKeys = async (categoryId?: number | null) => {
try { try {
const response = await apiClient.get("/multilang/keys"); const params = new URLSearchParams();
if (categoryId) {
params.append("categoryId", categoryId.toString());
}
const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiClient.get(url);
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
setLangKeys(data.data); setLangKeys(data.data);
} else {
// console.error("❌ 키 목록 로드 실패:", data.message);
} }
} catch (error) { } catch (error) {
// console.error("다국어 키 목록 조회 실패:", error); // console.error("다국어 키 목록 조회 실패:", error);
} }
}; };
// 필터링된 데이터 계산 - 메뉴관리와 동일한 방식 // 필터링된 데이터 계산
const getFilteredLangKeys = () => { const getFilteredLangKeys = () => {
let filteredKeys = langKeys; let filteredKeys = langKeys;
@ -146,16 +153,12 @@ export default function MultiLangPage() {
// 선택된 키의 다국어 텍스트 조회 // 선택된 키의 다국어 텍스트 조회
const fetchLangTexts = async (keyId: number) => { const fetchLangTexts = async (keyId: number) => {
try { try {
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`); const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
const data = response.data; const data = response.data;
// console.log("다국어 텍스트 조회 응답:", data);
if (data.success) { if (data.success) {
setLangTexts(data.data); setLangTexts(data.data);
// 편집용 텍스트 초기화
const editingData = data.data.map((text: LangText) => ({ ...text })); const editingData = data.data.map((text: LangText) => ({ ...text }));
setEditingTexts(editingData); setEditingTexts(editingData);
// console.log("편집용 텍스트 설정:", editingData);
} }
} catch (error) { } catch (error) {
// console.error("다국어 텍스트 조회 실패:", error); // console.error("다국어 텍스트 조회 실패:", error);
@ -164,20 +167,10 @@ export default function MultiLangPage() {
// 언어 키 선택 처리 // 언어 키 선택 처리
const handleKeySelect = (key: LangKey) => { const handleKeySelect = (key: LangKey) => {
// console.log("언어 키 선택:", key);
setSelectedKey(key); setSelectedKey(key);
fetchLangTexts(key.keyId); fetchLangTexts(key.keyId);
}; };
// 디버깅용 useEffect
useEffect(() => {
if (selectedKey) {
// console.log("선택된 키 변경:", selectedKey);
// console.log("언어 목록:", languages);
// console.log("편집 텍스트:", editingTexts);
}
}, [selectedKey, languages, editingTexts]);
// 텍스트 변경 처리 // 텍스트 변경 처리
const handleTextChange = (langCode: string, value: string) => { const handleTextChange = (langCode: string, value: string) => {
const newEditingTexts = [...editingTexts]; const newEditingTexts = [...editingTexts];
@ -203,7 +196,6 @@ export default function MultiLangPage() {
if (!selectedKey) return; if (!selectedKey) return;
try { try {
// 백엔드가 기대하는 형식으로 데이터 변환
const requestData = { const requestData = {
texts: editingTexts.map((text) => ({ texts: editingTexts.map((text) => ({
langCode: text.langCode, langCode: text.langCode,
@ -218,18 +210,16 @@ export default function MultiLangPage() {
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
alert("저장되었습니다."); alert("저장되었습니다.");
// 저장 후 다시 조회
fetchLangTexts(selectedKey.keyId); fetchLangTexts(selectedKey.keyId);
} }
} catch (error) { } catch (error) {
// console.error("텍스트 저장 실패:", error);
alert("저장에 실패했습니다."); alert("저장에 실패했습니다.");
} }
}; };
// 언어 키 추가/수정 모달 열기 // 언어 키 추가/수정 모달 열기
const handleAddKey = () => { const handleAddKey = () => {
setEditingKey(null); // 새 키 추가는 null로 설정 setEditingKey(null);
setIsModalOpen(true); setIsModalOpen(true);
}; };
@ -266,12 +256,11 @@ export default function MultiLangPage() {
if (result.success) { if (result.success) {
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다."); alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
setIsLanguageModalOpen(false); setIsLanguageModalOpen(false);
fetchLanguages(); // 언어 목록 새로고침 fetchLanguages();
} else { } else {
alert(`오류: ${result.message}`); alert(`오류: ${result.message}`);
} }
} catch (error) { } catch (error) {
// console.error("언어 저장 중 오류:", error);
alert("언어 저장 중 오류가 발생했습니다."); alert("언어 저장 중 오류가 발생했습니다.");
} }
}; };
@ -302,12 +291,11 @@ export default function MultiLangPage() {
if (failedDeletes.length === 0) { if (failedDeletes.length === 0) {
alert("선택된 언어가 삭제되었습니다."); alert("선택된 언어가 삭제되었습니다.");
setSelectedLanguages(new Set()); setSelectedLanguages(new Set());
fetchLanguages(); // 언어 목록 새로고침 fetchLanguages();
} else { } else {
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`); alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
} }
} catch (error) { } catch (error) {
// console.error("언어 삭제 중 오류:", error);
alert("언어 삭제 중 오류가 발생했습니다."); alert("언어 삭제 중 오류가 발생했습니다.");
} }
}; };
@ -358,10 +346,9 @@ export default function MultiLangPage() {
if (data.success) { if (data.success) {
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다."); alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
fetchLangKeys(); // 목록 새로고침 fetchLangKeys();
setIsModalOpen(false); setIsModalOpen(false);
} else { } else {
// 중복 체크 오류 메시지 처리
if (data.message && data.message.includes("이미 존재하는 언어키")) { if (data.message && data.message.includes("이미 존재하는 언어키")) {
alert(data.message); alert(data.message);
} else { } else {
@ -369,7 +356,6 @@ export default function MultiLangPage() {
} }
} }
} catch (error) { } catch (error) {
// console.error("언어 키 저장 실패:", error);
alert("언어 키 저장에 실패했습니다."); alert("언어 키 저장에 실패했습니다.");
} }
}; };
@ -397,7 +383,6 @@ export default function MultiLangPage() {
alert("상태 변경 중 오류가 발생했습니다."); alert("상태 변경 중 오류가 발생했습니다.");
} }
} catch (error) { } catch (error) {
// console.error("키 상태 토글 실패:", error);
alert("키 상태 변경 중 오류가 발생했습니다."); alert("키 상태 변경 중 오류가 발생했습니다.");
} }
}; };
@ -414,7 +399,6 @@ export default function MultiLangPage() {
alert("언어 상태 변경 중 오류가 발생했습니다."); alert("언어 상태 변경 중 오류가 발생했습니다.");
} }
} catch (error) { } catch (error) {
// console.error("언어 상태 토글 실패:", error);
alert("언어 상태 변경 중 오류가 발생했습니다."); alert("언어 상태 변경 중 오류가 발생했습니다.");
} }
}; };
@ -453,9 +437,8 @@ export default function MultiLangPage() {
if (allSuccess) { if (allSuccess) {
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`); alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
setSelectedKeys(new Set()); setSelectedKeys(new Set());
fetchLangKeys(); // 목록 새로고침 fetchLangKeys();
// 선택된 키가 삭제된 경우 편집 영역 닫기
if (selectedKey && selectedKeys.has(selectedKey.keyId)) { if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
handleCancel(); handleCancel();
} }
@ -463,12 +446,11 @@ export default function MultiLangPage() {
alert("일부 키 삭제에 실패했습니다."); alert("일부 키 삭제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
// console.error("선택된 키 삭제 실패:", error);
alert("선택된 키 삭제에 실패했습니다."); alert("선택된 키 삭제에 실패했습니다.");
} }
}; };
// 개별 키 삭제 (기존 함수 유지) // 개별 키 삭제
const handleDeleteKey = async (keyId: number) => { const handleDeleteKey = async (keyId: number) => {
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.")) { if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.")) {
return; return;
@ -479,13 +461,12 @@ export default function MultiLangPage() {
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
alert("언어 키가 영구적으로 삭제되었습니다."); alert("언어 키가 영구적으로 삭제되었습니다.");
fetchLangKeys(); // 목록 새로고침 fetchLangKeys();
if (selectedKey && selectedKey.keyId === keyId) { if (selectedKey && selectedKey.keyId === keyId) {
handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기 handleCancel();
} }
} }
} catch (error) { } catch (error) {
// console.error("언어 키 삭제 실패:", error);
alert("언어 키 삭제에 실패했습니다."); alert("언어 키 삭제에 실패했습니다.");
} }
}; };
@ -506,7 +487,12 @@ export default function MultiLangPage() {
initializeData(); initializeData();
}, []); }, []);
// 검색 관련 useEffect 제거 - 실시간 필터링만 사용 // 카테고리 변경 시 키 목록 다시 조회
useEffect(() => {
if (!loading) {
fetchLangKeys(selectedCategory?.categoryId);
}
}, [selectedCategory?.categoryId]);
const columns = [ const columns = [
{ {
@ -552,7 +538,6 @@ export default function MultiLangPage() {
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span> <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
), ),
}, },
{ {
accessorKey: "langKey", accessorKey: "langKey",
header: "언어 키", header: "언어 키",
@ -567,7 +552,6 @@ export default function MultiLangPage() {
</div> </div>
), ),
}, },
{ {
accessorKey: "description", accessorKey: "description",
header: "설명", header: "설명",
@ -667,193 +651,253 @@ export default function MultiLangPage() {
} }
return ( return (
<div className="container mx-auto p-2"> <div className="min-h-screen bg-gray-50">
{/* 탭 네비게이션 */} <div className="w-full max-w-none px-4 py-8">
<div className="flex space-x-1 border-b"> <div className="container mx-auto p-2">
<button {/* 탭 네비게이션 */}
onClick={() => setActiveTab("keys")} <div className="flex space-x-1 border-b">
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${ <button
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200" onClick={() => setActiveTab("keys")}
}`} className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
> activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
</button> >
<button
onClick={() => setActiveTab("languages")} </button>
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${ <button
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200" onClick={() => setActiveTab("languages")}
}`} className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
> activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
</button> >
</div>
</button>
{/* 메인 콘텐츠 영역 */}
<div className="mt-2">
{/* 언어 관리 탭 */}
{activeTab === "languages" && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {languages.length} .</div>
<div className="flex space-x-2">
{selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}>
({selectedLanguages.size})
</Button>
)}
<Button onClick={handleAddLanguage}> </Button>
</div>
</div>
<DataTable data={languages} columns={languageColumns} searchable />
</CardContent>
</Card>
)}
{/* 다국어 키 관리 탭의 메인 영역 */}
{activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
{/* 좌측: 언어 키 목록 (7/10) */}
<Card className="lg:col-span-7">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> </CardTitle>
<div className="flex space-x-2">
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
({selectedKeys.size})
</Button>
<Button onClick={handleAddKey}> </Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div>
<Label htmlFor="company"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger>
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="search"></Label>
<Input
placeholder="키명, 설명, 메뉴, 회사로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="flex items-end">
<div className="text-sm text-muted-foreground"> : {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<div className="mb-2 text-sm text-muted-foreground">: {getFilteredLangKeys().length}</div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}
searchable={false}
onRowClick={handleKeySelect}
/>
</div>
</CardContent>
</Card>
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>
{selectedKey ? (
<>
:{" "}
<Badge variant="secondary" className="ml-2">
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
</Badge>
</>
) : (
"다국어 편집"
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedKey ? (
<div>
{/* 스크롤 가능한 텍스트 영역 */}
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
{languages
.filter((lang) => lang.isActive === "Y")
.map((lang) => {
const text = editingTexts.find((t) => t.langCode === lang.langCode);
return (
<div key={lang.langCode} className="flex items-center space-x-4">
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
{lang.langName}
</Badge>
<Input
placeholder={`${lang.langName} 텍스트 입력`}
value={text?.langText || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
className="flex-1"
/>
</div>
);
})}
</div>
{/* 저장 버튼 - 고정 위치 */}
<div className="mt-4 flex space-x-2 border-t pt-4">
<Button onClick={handleSave}></Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
</div>
</div>
) : (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> </div>
</div>
</div>
)}
</CardContent>
</Card>
</div> </div>
)}
{/* 메인 콘텐츠 영역 */}
<div className="mt-2">
{/* 언어 관리 탭 */}
{activeTab === "languages" && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {languages.length} .</div>
<div className="flex space-x-2">
{selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}>
({selectedLanguages.size})
</Button>
)}
<Button onClick={handleAddLanguage}> </Button>
</div>
</div>
<DataTable data={languages} columns={languageColumns} searchable />
</CardContent>
</Card>
)}
{/* 다국어 키 관리 탭 */}
{activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* 좌측: 카테고리 트리 (2/12) */}
<Card className="lg:col-span-2">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"></CardTitle>
</div>
</CardHeader>
<CardContent className="p-2">
<ScrollArea className="h-[500px]">
<CategoryTree
selectedCategoryId={selectedCategory?.categoryId || null}
onSelectCategory={(cat) => setSelectedCategory(cat)}
onDoubleClickCategory={(cat) => {
setSelectedCategory(cat);
setIsGenerateModalOpen(true);
}}
/>
</ScrollArea>
</CardContent>
</Card>
{/* 중앙: 언어 키 목록 (6/12) */}
<Card className="lg:col-span-6">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedCategory && (
<Badge variant="secondary" className="ml-2">
{selectedCategory.categoryName}
</Badge>
)}
</CardTitle>
<div className="flex space-x-2">
<Button
size="sm"
variant="destructive"
onClick={handleDeleteSelectedKeys}
disabled={selectedKeys.size === 0}
>
({selectedKeys.size})
</Button>
<Button size="sm" variant="outline" onClick={handleAddKey}>
</Button>
<Button
size="sm"
onClick={() => setIsGenerateModalOpen(true)}
disabled={!selectedCategory}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div>
<Label htmlFor="company" className="text-xs"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="search" className="text-xs"></Label>
<Input
placeholder="키명, 설명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex items-end">
<div className="text-xs text-muted-foreground">: {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}
searchable={false}
onRowClick={handleKeySelect}
/>
</div>
</CardContent>
</Card>
{/* 우측: 선택된 키의 다국어 관리 (4/12) */}
<Card className="lg:col-span-4">
<CardHeader>
<CardTitle>
{selectedKey ? (
<>
:{" "}
<Badge variant="secondary" className="ml-2">
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
</Badge>
</>
) : (
"다국어 편집"
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedKey ? (
<div>
{/* 스크롤 가능한 텍스트 영역 */}
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
{languages
.filter((lang) => lang.isActive === "Y")
.map((lang) => {
const text = editingTexts.find((t) => t.langCode === lang.langCode);
return (
<div key={lang.langCode} className="flex items-center space-x-4">
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
{lang.langName}
</Badge>
<Input
placeholder={`${lang.langName} 텍스트 입력`}
value={text?.langText || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
className="flex-1"
/>
</div>
);
})}
</div>
{/* 저장 버튼 - 고정 위치 */}
<div className="mt-4 flex space-x-2 border-t pt-4">
<Button onClick={handleSave}></Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
</div>
</div>
) : (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> </div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
{/* 언어 키 추가/수정 모달 */}
<LangKeyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveKey}
keyData={editingKey}
companies={companies}
/>
{/* 언어 추가/수정 모달 */}
<LanguageModal
isOpen={isLanguageModalOpen}
onClose={() => setIsLanguageModalOpen(false)}
onSave={handleSaveLanguage}
languageData={editingLanguage}
/>
{/* 키 자동 생성 모달 */}
<KeyGenerateModal
isOpen={isGenerateModalOpen}
onClose={() => setIsGenerateModalOpen(false)}
selectedCategory={selectedCategory}
companyCode={user?.companyCode || ""}
isSuperAdmin={user?.companyCode === "*"}
onSuccess={() => {
fetchLangKeys(selectedCategory?.categoryId);
}}
/>
</div>
</div> </div>
{/* 언어 키 추가/수정 모달 */}
<LangKeyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveKey}
keyData={editingKey}
companies={companies}
/>
{/* 언어 추가/수정 모달 */}
<LanguageModal
isOpen={isLanguageModalOpen}
onClose={() => setIsLanguageModalOpen(false)}
onSave={handleSaveLanguage}
languageData={editingLanguage}
/>
</div> </div>
); );
} }

View File

@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
@ -90,6 +93,13 @@ export default function TableManagementPage() {
// 🎯 Entity 조인 관련 상태 // 🎯 Entity 조인 관련 상태
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({}); const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
// 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리)
const [entityComboboxOpen, setEntityComboboxOpen] = useState<Record<string, {
table: boolean;
joinColumn: boolean;
displayColumn: boolean;
}>>({});
// DDL 기능 관련 상태 // DDL 기능 관련 상태
const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
@ -1388,113 +1398,266 @@ export default function TableManagementPage() {
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && ( {column.inputType === "entity" && (
<> <>
{/* 참조 테이블 */} {/* 참조 테이블 - 검색 가능한 Combobox */}
<div className="w-48"> <div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label> <label className="text-muted-foreground mb-1 block text-xs"> </label>
<Select <Popover
value={column.referenceTable || "none"} open={entityComboboxOpen[column.columnName]?.table || false}
onValueChange={(value) => onOpenChange={(open) =>
handleDetailSettingsChange(column.columnName, "entity", value) setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], table: open },
}))
} }
> >
<SelectTrigger className="bg-background h-8 w-full text-xs"> <PopoverTrigger asChild>
<SelectValue placeholder="선택" /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
{referenceTableOptions.map((option, index) => ( aria-expanded={entityComboboxOpen[column.columnName]?.table || false}
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}> className="bg-background h-8 w-full justify-between text-xs"
<div className="flex flex-col"> >
<span className="font-medium">{option.label}</span> {column.referenceTable && column.referenceTable !== "none"
<span className="text-muted-foreground text-xs">{option.value}</span> ? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label ||
</div> column.referenceTable
</SelectItem> : "테이블 선택..."}
))} <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</SelectContent> </Button>
</Select> </PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{referenceTableOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity", option.value);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], table: false },
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceTable === option.value ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
{option.value !== "none" && (
<span className="text-muted-foreground text-[10px]">{option.value}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
{/* 조인 컬럼 */} {/* 조인 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable && column.referenceTable !== "none" && ( {column.referenceTable && column.referenceTable !== "none" && (
<div className="w-48"> <div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label> <label className="text-muted-foreground mb-1 block text-xs"> </label>
<Select <Popover
value={column.referenceColumn || "none"} open={entityComboboxOpen[column.columnName]?.joinColumn || false}
onValueChange={(value) => onOpenChange={(open) =>
handleDetailSettingsChange( setEntityComboboxOpen((prev) => ({
column.columnName, ...prev,
"entity_reference_column", [column.columnName]: { ...prev[column.columnName], joinColumn: open },
value, }))
)
} }
> >
<SelectTrigger className="bg-background h-8 w-full text-xs"> <PopoverTrigger asChild>
<SelectValue placeholder="선택" /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
<SelectItem value="none">-- --</SelectItem> aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false}
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => ( className="bg-background h-8 w-full justify-between text-xs"
<SelectItem disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0}
key={`ref-col-${refCol.columnName}-${index}`} >
value={refCol.columnName} {!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
> <span className="flex items-center gap-2">
<span className="font-medium">{refCol.columnName}</span>
</SelectItem>
))}
{(!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div> <div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
...
</div> </span>
</SelectItem> ) : column.referenceColumn && column.referenceColumn !== "none" ? (
)} column.referenceColumn
</SelectContent> ) : (
</Select> "컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_reference_column", "none");
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceColumn === "none" || !column.referenceColumn ? "opacity-100" : "opacity-0",
)}
/>
-- --
</CommandItem>
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
)} )}
{/* 표시 컬럼 */} {/* 표시 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable && {column.referenceTable &&
column.referenceTable !== "none" && column.referenceTable !== "none" &&
column.referenceColumn && column.referenceColumn &&
column.referenceColumn !== "none" && ( column.referenceColumn !== "none" && (
<div className="w-48"> <div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label> <label className="text-muted-foreground mb-1 block text-xs"> </label>
<Select <Popover
value={column.displayColumn || "none"} open={entityComboboxOpen[column.columnName]?.displayColumn || false}
onValueChange={(value) => onOpenChange={(open) =>
handleDetailSettingsChange( setEntityComboboxOpen((prev) => ({
column.columnName, ...prev,
"entity_display_column", [column.columnName]: { ...prev[column.columnName], displayColumn: open },
value, }))
)
} }
> >
<SelectTrigger className="bg-background h-8 w-full text-xs"> <PopoverTrigger asChild>
<SelectValue placeholder="선택" /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
<SelectItem value="none">-- --</SelectItem> aria-expanded={entityComboboxOpen[column.columnName]?.displayColumn || false}
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => ( className="bg-background h-8 w-full justify-between text-xs"
<SelectItem disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0}
key={`ref-col-${refCol.columnName}-${index}`} >
value={refCol.columnName} {!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
> <span className="flex items-center gap-2">
<span className="font-medium">{refCol.columnName}</span>
</SelectItem>
))}
{(!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div> <div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
...
</div> </span>
</SelectItem> ) : column.displayColumn && column.displayColumn !== "none" ? (
)} column.displayColumn
</SelectContent> ) : (
</Select> "컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_display_column", "none");
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === "none" || !column.displayColumn ? "opacity-100" : "opacity-0",
)}
/>
-- --
</CommandItem>
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => {
handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
)} )}
@ -1505,8 +1668,8 @@ export default function TableManagementPage() {
column.referenceColumn !== "none" && column.referenceColumn !== "none" &&
column.displayColumn && column.displayColumn &&
column.displayColumn !== "none" && ( column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs"> <div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
<span></span> <Check className="h-3 w-3" />
<span className="truncate"> </span> <span className="truncate"> </span>
</div> </div>
)} )}

View File

@ -1,31 +0,0 @@
"use client";
import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/userAuth
*
*
* (SUPER_ADMIN, COMPANY_ADMIN, USER )
*/
export default function UserAuthPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> . ( )</p>
</div>
{/* 메인 컨텐츠 */}
<UserAuthManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -1,25 +1,23 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "./DepartmentStructure"; import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
import { DepartmentMembers } from "./DepartmentMembers"; import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
import type { Department } from "@/types/department"; import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company"; import { getCompanyList } from "@/lib/api/company";
interface DepartmentManagementProps {
companyCode: string;
}
/** /**
* *
* 좌측: 부서 , 우측: 부서 * 좌측: 부서 , 우측: 부서
*/ */
export function DepartmentManagement({ companyCode }: DepartmentManagementProps) { export default function DepartmentManagementPage() {
const params = useParams();
const router = useRouter(); const router = useRouter();
const companyCode = params.companyCode as string;
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null); const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure"); const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>(""); const [companyName, setCompanyName] = useState<string>("");
@ -45,7 +43,7 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
}, [companyCode]); }, [companyCode]);
const handleBackToList = () => { const handleBackToList = () => {
router.push("/admin/company"); router.push("/admin/userMng/companyList");
}; };
return ( return (

View File

@ -0,0 +1,105 @@
"use client";
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
import { CompanyTable } from "@/components/admin/CompanyTable";
import { CompanyFormModal } from "@/components/admin/CompanyFormModal";
import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog";
import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
*
*/
export default function CompanyPage() {
const {
// 데이터
companies,
searchFilter,
isLoading,
error,
// 디스크 사용량 관련
diskUsageInfo,
isDiskUsageLoading,
loadDiskUsage,
// 모달 상태
modalState,
deleteState,
// 검색 기능
updateSearchFilter,
clearSearchFilter,
// 모달 제어
openCreateModal,
openEditModal,
closeModal,
updateFormData,
// 삭제 다이얼로그 제어
openDeleteDialog,
closeDeleteDialog,
// CRUD 작업
saveCompany,
deleteCompany,
// 에러 처리
clearError,
} = useCompanyManagement();
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 디스크 사용량 요약 */}
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
{/* 툴바 - 검색, 필터, 등록 버튼 */}
<CompanyToolbar
searchFilter={searchFilter}
totalCount={companies.length}
filteredCount={companies.length}
onSearchChange={updateSearchFilter}
onSearchClear={clearSearchFilter}
onCreateClick={openCreateModal}
/>
{/* 회사 목록 테이블 */}
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} />
{/* 회사 등록/수정 모달 */}
<CompanyFormModal
modalState={modalState}
isLoading={isLoading}
error={error}
onClose={closeModal}
onSave={saveCompany}
onFormChange={updateFormData}
onClearError={clearError}
/>
{/* 회사 삭제 확인 다이얼로그 */}
<CompanyDeleteDialog
deleteState={deleteState}
isLoading={isLoading}
error={error}
onClose={closeDeleteDialog}
onConfirm={deleteCompany}
onClearError={clearError}
/>
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,30 +0,0 @@
"use client";
import { UserManagement } from "@/components/admin/UserManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/userMng
*
* shadcn/ui
*/
export default function UserMngPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<UserManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -1,29 +1,28 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect } from "react";
import { use } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react"; import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role"; import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { DualListBox } from "@/components/common/DualListBox"; import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "./MenuPermissionsTable"; import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
import { ScrollToTop } from "@/components/common/ScrollToTop";
interface RoleDetailManagementProps {
roleId: string;
}
/** /**
* *
* URL: /admin/userMng/rolesList/[id]
* *
* : * :
* - * - (Dual List Box)
* - (Dual List Box)
* - (CRUD ) * - (CRUD )
*/ */
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
const { id: roleId } = use(params);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const router = useRouter(); const router = useRouter();
const { refreshMenus } = useMenu(); const { refreshMenus } = useMenu();
@ -236,7 +235,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
<AlertCircle className="text-destructive mb-4 h-12 w-12" /> <AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3> <h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p> <p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
<Button variant="outline" onClick={() => router.push("/admin/roles")}> <Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
</Button> </Button>
</div> </div>
@ -244,102 +243,107 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} }
return ( return (
<> <div className="bg-background flex min-h-screen flex-col">
{/* 페이지 헤더 */} <div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4"> {/* 페이지 헤더 */}
<div className="flex items-center gap-4"> <div className="space-y-2 border-b pb-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/roles")} className="h-10 w-10"> <div className="flex items-center gap-4">
<ArrowLeft className="h-5 w-5" /> <Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
</Button> <ArrowLeft className="h-5 w-5" />
<div className="flex-1"> </Button>
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1> <div className="flex-1">
<p className="text-muted-foreground text-sm"> <h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
{roleGroup.authCode} {roleGroup.companyCode} <p className="text-muted-foreground text-sm">
</p> {roleGroup.authCode} {roleGroup.companyCode}
</p>
</div>
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{roleGroup.status === "active" ? "활성" : "비활성"}
</span>
</div> </div>
<span </div>
className={`rounded-full px-3 py-1 text-sm font-medium ${
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800" {/* 탭 네비게이션 */}
<div className="flex gap-4 border-b">
<button
onClick={() => setActiveTab("members")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "members"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`} }`}
> >
{roleGroup.status === "active" ? "활성" : "비활성"} <Users className="h-4 w-4" />
</span> ({selectedUsers.length})
</button>
<button
onClick={() => setActiveTab("permissions")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "permissions"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<MenuIcon className="h-4 w-4" />
({menuPermissions.length})
</button>
</div>
{/* 탭 컨텐츠 */}
<div className="space-y-6">
{activeTab === "members" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
<Save className="h-4 w-4" />
{isSavingMembers ? "저장 중..." : "멤버 저장"}
</Button>
</div>
<DualListBox
availableItems={availableUsers}
selectedItems={selectedUsers}
onSelectionChange={setSelectedUsers}
availableLabel="전체 사용자"
selectedLabel="그룹 멤버"
enableSearch
/>
</>
)}
{activeTab === "permissions" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
<Save className="h-4 w-4" />
{isSavingPermissions ? "저장 중..." : "권한 저장"}
</Button>
</div>
<MenuPermissionsTable
permissions={menuPermissions}
onPermissionsChange={setMenuPermissions}
roleGroup={roleGroup}
/>
</>
)}
</div> </div>
</div> </div>
{/* 탭 네비게이션 */} {/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<div className="flex gap-4 border-b"> <ScrollToTop />
<button </div>
onClick={() => setActiveTab("members")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "members"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<Users className="h-4 w-4" />
({selectedUsers.length})
</button>
<button
onClick={() => setActiveTab("permissions")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "permissions"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<MenuIcon className="h-4 w-4" />
({menuPermissions.length})
</button>
</div>
{/* 탭 컨텐츠 */}
<div className="space-y-6">
{activeTab === "members" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
<Save className="h-4 w-4" />
{isSavingMembers ? "저장 중..." : "멤버 저장"}
</Button>
</div>
<DualListBox
availableItems={availableUsers}
selectedItems={selectedUsers}
onSelectionChange={setSelectedUsers}
availableLabel="전체 사용자"
selectedLabel="그룹 멤버"
enableSearch
/>
</>
)}
{activeTab === "permissions" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
<Save className="h-4 w-4" />
{isSavingPermissions ? "저장 중..." : "권한 저장"}
</Button>
</div>
<MenuPermissionsTable
permissions={menuPermissions}
onPermissionsChange={setMenuPermissions}
roleGroup={roleGroup}
/>
</>
)}
</div>
</>
); );
} }

View File

@ -0,0 +1,364 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { RoleFormModal } from "@/components/admin/RoleFormModal";
import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
import { useRouter } from "next/navigation";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { companyAPI } from "@/lib/api/company";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/roles
*
* shadcn/ui
*
* :
* -
* - //
* - (Dual List Box)
* - (CRUD )
* - ( + )
*/
export default function RolesPage() {
const { user: currentUser } = useAuth();
const router = useRouter();
// 회사 관리자 또는 최고 관리자 여부
const isAdmin =
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
currentUser?.userType === "COMPANY_ADMIN";
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 회사 필터 (최고 관리자 전용)
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
const [selectedCompany, setSelectedCompany] = useState<string>("all");
// 모달 상태
const [formModal, setFormModal] = useState({
isOpen: false,
editingRole: null as RoleGroup | null,
});
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
role: null as RoleGroup | null,
});
// 회사 목록 로드 (최고 관리자만)
const loadCompanies = useCallback(async () => {
if (!isSuperAdmin) return;
try {
const companies = await companyAPI.getList();
setCompanies(companies);
} catch (error) {
console.error("회사 목록 로드 오류:", error);
}
}, [isSuperAdmin]);
// 데이터 로드
const loadRoleGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
// 회사 관리자: 자기 회사만 조회
const companyFilter =
isSuperAdmin && selectedCompany !== "all"
? selectedCompany
: isSuperAdmin
? undefined
: currentUser?.companyCode;
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
const response = await roleAPI.getList({
companyCode: companyFilter,
});
if (response.success && response.data) {
setRoleGroups(response.data);
console.log("권한 그룹 조회 성공:", response.data.length, "개");
} else {
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("권한 그룹 목록 로드 오류:", err);
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
useEffect(() => {
if (isAdmin) {
if (isSuperAdmin) {
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
}
loadRoleGroups();
} else {
setIsLoading(false);
}
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
// 권한 그룹 생성 핸들러
const handleCreateRole = useCallback(() => {
setFormModal({ isOpen: true, editingRole: null });
}, []);
// 권한 그룹 수정 핸들러
const handleEditRole = useCallback((role: RoleGroup) => {
setFormModal({ isOpen: true, editingRole: role });
}, []);
// 권한 그룹 삭제 핸들러
const handleDeleteRole = useCallback((role: RoleGroup) => {
setDeleteModal({ isOpen: true, role });
}, []);
// 폼 모달 닫기
const handleFormModalClose = useCallback(() => {
setFormModal({ isOpen: false, editingRole: null });
}, []);
// 삭제 모달 닫기
const handleDeleteModalClose = useCallback(() => {
setDeleteModal({ isOpen: false, role: null });
}, []);
// 모달 성공 후 새로고침
const handleModalSuccess = useCallback(() => {
loadRoleGroups();
}, [loadRoleGroups]);
// 상세 페이지로 이동
const handleViewDetail = useCallback(
(role: RoleGroup) => {
router.push(`/admin/userMng/rolesList/${role.objid}`);
},
[router],
);
// 관리자가 아니면 접근 제한
if (!isAdmin) {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> ( )</p>
</div>
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
.
</p>
<Button variant="outline" onClick={() => window.history.back()}>
</Button>
</div>
</div>
<ScrollToTop />
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> ( )</p>
</div>
{/* 에러 메시지 */}
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={() => setError(null)}
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
{/* 액션 버튼 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold"> ({roleGroups.length})</h2>
{/* 최고 관리자 전용: 회사 필터 */}
{isSuperAdmin && (
<div className="flex items-center gap-2">
<Filter className="text-muted-foreground h-4 w-4" />
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
<SelectTrigger className="h-10 w-[200px]">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCompany !== "all" && (
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<Button onClick={handleCreateRole} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 권한 그룹 목록 */}
{isLoading ? (
<div className="bg-card rounded-lg border p-12 shadow-sm">
<div className="flex flex-col items-center justify-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
) : roleGroups.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{roleGroups.map((role) => (
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
{/* 헤더 (클릭 시 상세 페이지) */}
<div
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
onClick={() => handleViewDetail(role)}
>
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{role.authName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.authCode}</p>
</div>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{role.status === "active" ? "활성" : "비활성"}
</span>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
{/* 최고 관리자는 회사명 표시 */}
{isSuperAdmin && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3" />
</span>
<span className="font-medium">{role.memberCount || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Menu className="h-3 w-3" />
</span>
<span className="font-medium">{role.menuCount || 0}</span>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 border-t p-3">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditRole(role);
}}
className="flex-1 gap-1 text-xs"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteRole(role);
}}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 모달들 */}
<RoleFormModal
isOpen={formModal.isOpen}
onClose={handleFormModalClose}
onSuccess={handleModalSuccess}
editingRole={formModal.editingRole}
/>
<RoleDeleteModal
isOpen={deleteModal.isOpen}
onClose={handleDeleteModalClose}
onSuccess={handleModalSuccess}
role={deleteModal.role}
/>
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,181 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { UserAuthTable } from "@/components/admin/UserAuthTable";
import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal";
import { userAPI } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/userAuth
*
*
* (SUPER_ADMIN, COMPANY_ADMIN, USER )
*/
export default function UserAuthPage() {
const { user: currentUser } = useAuth();
// 최고 관리자 여부
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [users, setUsers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paginationInfo, setPaginationInfo] = useState({
currentPage: 1,
pageSize: 20,
totalItems: 0,
totalPages: 0,
});
// 권한 변경 모달
const [authEditModal, setAuthEditModal] = useState({
isOpen: false,
user: null as any | null,
});
// 데이터 로드
const loadUsers = useCallback(
async (page: number = 1) => {
setIsLoading(true);
setError(null);
try {
const response = await userAPI.getList({
page,
size: paginationInfo.pageSize,
});
if (response.success && response.data) {
setUsers(response.data);
setPaginationInfo({
currentPage: response.currentPage || page,
pageSize: response.pageSize || paginationInfo.pageSize,
totalItems: response.total || 0,
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
});
} else {
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("사용자 목록 로드 오류:", err);
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[paginationInfo.pageSize],
);
useEffect(() => {
loadUsers(1);
}, []);
// 권한 변경 핸들러
const handleEditAuth = (user: any) => {
setAuthEditModal({
isOpen: true,
user,
});
};
// 권한 변경 모달 닫기
const handleAuthEditClose = () => {
setAuthEditModal({
isOpen: false,
user: null,
});
};
// 권한 변경 성공
const handleAuthEditSuccess = () => {
loadUsers(paginationInfo.currentPage);
handleAuthEditClose();
};
// 페이지 변경
const handlePageChange = (page: number) => {
loadUsers(page);
};
// 최고 관리자가 아닌 경우
if (!isSuperAdmin) {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> . ( )</p>
</div>
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
.
</p>
<Button variant="outline" onClick={() => window.history.back()}>
</Button>
</div>
</div>
<ScrollToTop />
</div>
);
}
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> . ( )</p>
</div>
{/* 에러 메시지 */}
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={() => setError(null)}
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
{/* 사용자 권한 테이블 */}
<UserAuthTable
users={users}
isLoading={isLoading}
paginationInfo={paginationInfo}
onEditAuth={handleEditAuth}
onPageChange={handlePageChange}
/>
{/* 권한 변경 모달 */}
<UserAuthEditModal
isOpen={authEditModal.isOpen}
onClose={handleAuthEditClose}
onSuccess={handleAuthEditSuccess}
user={authEditModal.user}
/>
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { useUserManagement } from "@/hooks/useUserManagement";
import { UserToolbar } from "@/components/admin/UserToolbar";
import { UserTable } from "@/components/admin/UserTable";
import { Pagination } from "@/components/common/Pagination";
import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
import { UserFormModal } from "@/components/admin/UserFormModal";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/userMng
*
* shadcn/ui
* - Spring + JSP REST API
* -
*/
export default function UserMngPage() {
const {
// 데이터
users,
searchFilter,
isLoading,
isSearching,
error,
paginationInfo,
// 검색 기능
updateSearchFilter,
// 페이지네이션
handlePageChange,
handlePageSizeChange,
// 액션 핸들러
handleStatusToggle,
// 유틸리티
clearError,
refreshData,
} = useUserManagement();
// 비밀번호 초기화 모달 상태
const [passwordResetModal, setPasswordResetModal] = useState({
isOpen: false,
userId: null as string | null,
userName: null as string | null,
});
// 사용자 등록/수정 모달 상태
const [userFormModal, setUserFormModal] = useState({
isOpen: false,
editingUser: null as any | null,
});
// 사용자 등록 핸들러
const handleCreateUser = () => {
setUserFormModal({
isOpen: true,
editingUser: null,
});
};
// 사용자 수정 핸들러
const handleEditUser = (user: any) => {
setUserFormModal({
isOpen: true,
editingUser: user,
});
};
// 사용자 등록/수정 모달 닫기
const handleUserFormClose = () => {
setUserFormModal({
isOpen: false,
editingUser: null,
});
};
// 사용자 등록/수정 성공 핸들러
const handleUserFormSuccess = () => {
refreshData();
handleUserFormClose();
};
// 비밀번호 초기화 핸들러
const handlePasswordReset = (userId: string, userName: string) => {
setPasswordResetModal({
isOpen: true,
userId,
userName,
});
};
// 비밀번호 초기화 모달 닫기
const handlePasswordResetClose = () => {
setPasswordResetModal({
isOpen: false,
userId: null,
userName: null,
});
};
// 비밀번호 초기화 성공 핸들러
const handlePasswordResetSuccess = () => {
handlePasswordResetClose();
};
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 툴바 - 검색, 필터, 등록 버튼 */}
<UserToolbar
searchFilter={searchFilter}
totalCount={paginationInfo.totalItems}
isSearching={isSearching}
onSearchChange={updateSearchFilter}
onCreateClick={handleCreateUser}
/>
{/* 에러 메시지 */}
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={clearError}
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
{/* 사용자 목록 테이블 */}
<UserTable
users={users}
isLoading={isLoading}
paginationInfo={paginationInfo}
onStatusToggle={handleStatusToggle}
onPasswordReset={handlePasswordReset}
onEdit={handleEditUser}
/>
{/* 페이지네이션 */}
{!isLoading && users.length > 0 && (
<Pagination
paginationInfo={paginationInfo}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showPageSizeSelector={true}
pageSizeOptions={[10, 20, 50, 100]}
className="mt-6"
/>
)}
{/* 사용자 등록/수정 모달 */}
<UserFormModal
isOpen={userFormModal.isOpen}
onClose={handleUserFormClose}
onSuccess={handleUserFormSuccess}
editingUser={userFormModal.editingUser}
/>
{/* 비밀번호 초기화 모달 */}
<UserPasswordResetModal
isOpen={passwordResetModal.isOpen}
onClose={handlePasswordResetClose}
userId={passwordResetModal.userId}
userName={passwordResetModal.userName}
onSuccess={handlePasswordResetSuccess}
/>
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

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