Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
e42c11249d
|
|
@ -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 |
|
|
@ -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",
|
||||||
|
|
@ -3214,6 +3215,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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"; // 디지털 트윈 (야드 관제)
|
||||||
|
|
@ -220,6 +221,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); // 파일이 존재하지 않음
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export class EntityJoinController {
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
|
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -139,6 +140,24 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 중복 제거 설정 처리
|
||||||
|
let parsedDeduplication: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
} | undefined = undefined;
|
||||||
|
if (deduplication) {
|
||||||
|
try {
|
||||||
|
parsedDeduplication =
|
||||||
|
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
|
||||||
|
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("중복 제거 설정 파싱 오류:", error);
|
||||||
|
parsedDeduplication = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -156,13 +175,26 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
|
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리 (결과 데이터에 적용)
|
||||||
|
let finalData = result;
|
||||||
|
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
|
||||||
|
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
|
||||||
|
const originalCount = result.data.length;
|
||||||
|
finalData = {
|
||||||
|
...result,
|
||||||
|
data: this.deduplicateData(result.data, parsedDeduplication),
|
||||||
|
};
|
||||||
|
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Entity 조인 데이터 조회 성공",
|
message: "Entity 조인 데이터 조회 성공",
|
||||||
data: result,
|
data: finalData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||||
|
|
@ -537,6 +569,98 @@ export class EntityJoinController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 데이터 제거 (메모리 내 처리)
|
||||||
|
*/
|
||||||
|
private deduplicateData(
|
||||||
|
data: any[],
|
||||||
|
config: {
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
): any[] {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
// 그룹별로 데이터 분류
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const groupKey = row[config.groupByColumn];
|
||||||
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 그룹에서 하나의 행만 선택
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
|
let selectedRow: any;
|
||||||
|
|
||||||
|
switch (config.keepStrategy) {
|
||||||
|
case "latest":
|
||||||
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal > bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "earliest":
|
||||||
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "base_price":
|
||||||
|
// base_price가 true인 행 선택
|
||||||
|
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "current_date":
|
||||||
|
// 오늘 날짜 기준 유효 기간 내 행 선택
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
selectedRow = rows.find((r) => {
|
||||||
|
const startDate = r.start_date;
|
||||||
|
const endDate = r.end_date;
|
||||||
|
if (!startDate) return true;
|
||||||
|
if (startDate <= today && (!endDate || endDate >= today)) return true;
|
||||||
|
return false;
|
||||||
|
}) || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
selectedRow = rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRow) {
|
||||||
|
result.push(selectedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entityJoinController = new EntityJoinController();
|
export const entityJoinController = new EntityJoinController();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
let operator = "=";
|
||||||
|
|
||||||
|
if (key.includes("__")) {
|
||||||
|
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);
|
params.push(value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
} else {
|
break;
|
||||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -775,7 +775,8 @@ 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) {
|
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
|
||||||
|
if (userValue && userValue !== "*") {
|
||||||
enhancedSearch[filterColumn] = userValue;
|
enhancedSearch[filterColumn] = userValue;
|
||||||
|
|
||||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||||
|
|
@ -784,6 +785,10 @@ export async function getTableData(
|
||||||
userValue,
|
userValue,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
|
} else if (userValue === "*") {
|
||||||
|
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
|
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
|
||||||
userField,
|
userField,
|
||||||
|
|
@ -792,6 +797,9 @@ export async function getTableData(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 최종 검색 조건 로그
|
||||||
|
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회
|
||||||
const result = await tableManagementService.getTableData(tableName, {
|
const result = await tableManagementService.getTableData(tableName, {
|
||||||
page: parseInt(page),
|
page: parseInt(page),
|
||||||
|
|
@ -893,13 +901,23 @@ export async function addTableData(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 추가
|
// 데이터 추가
|
||||||
await tableManagementService.addTableData(tableName, data);
|
const result = await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
// 무시된 컬럼이 있으면 경고 정보 포함
|
||||||
|
const response: ApiResponse<{
|
||||||
|
skippedColumns?: string[];
|
||||||
|
savedColumns?: string[];
|
||||||
|
}> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
message: result.skippedColumns.length > 0
|
||||||
|
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
|
||||||
|
: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||||
|
data: {
|
||||||
|
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
|
||||||
|
savedColumns: result.savedColumns,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(201).json(response);
|
res.status(201).json(response);
|
||||||
|
|
@ -1973,15 +1991,21 @@ export async function multiTableSave(
|
||||||
for (const subTableConfig of subTables || []) {
|
for (const subTableConfig of subTables || []) {
|
||||||
const { tableName, linkColumn, items, options } = subTableConfig;
|
const { tableName, linkColumn, items, options } = subTableConfig;
|
||||||
|
|
||||||
if (!tableName || !items || items.length === 0) {
|
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
|
||||||
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
|
||||||
|
options?.mainFieldMappings &&
|
||||||
|
options.mainFieldMappings.length > 0;
|
||||||
|
|
||||||
|
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
|
||||||
|
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||||
itemsCount: items.length,
|
itemsCount: items?.length || 0,
|
||||||
linkColumn,
|
linkColumn,
|
||||||
options,
|
options,
|
||||||
|
hasSaveMainAsFirst,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기존 데이터 삭제 옵션
|
// 기존 데이터 삭제 옵션
|
||||||
|
|
@ -1999,7 +2023,15 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||||
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
|
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
|
||||||
|
logger.info(`saveMainAsFirst 옵션 확인:`, {
|
||||||
|
saveMainAsFirst: options?.saveMainAsFirst,
|
||||||
|
mainFieldMappings: options?.mainFieldMappings,
|
||||||
|
mainFieldMappingsLength: options?.mainFieldMappings?.length,
|
||||||
|
linkColumn,
|
||||||
|
mainDataKeys: Object.keys(mainData),
|
||||||
|
});
|
||||||
|
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
|
||||||
const mainSubItem: Record<string, any> = {
|
const mainSubItem: Record<string, any> = {
|
||||||
[linkColumn.subColumn]: savedPkValue,
|
[linkColumn.subColumn]: savedPkValue,
|
||||||
};
|
};
|
||||||
|
|
@ -2153,3 +2185,67 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
|
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||||
|
*
|
||||||
|
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||||
|
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||||
|
*/
|
||||||
|
export async function getTableEntityRelations(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { leftTable, rightTable } = req.query;
|
||||||
|
|
||||||
|
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
|
||||||
|
|
||||||
|
if (!leftTable || !rightTable) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "leftTable과 rightTable 파라미터가 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_PARAMETERS",
|
||||||
|
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
const relations = await tableManagementService.detectTableEntityRelations(
|
||||||
|
String(leftTable),
|
||||||
|
String(rightTable)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
|
||||||
|
|
||||||
|
const response: ApiResponse<any> = {
|
||||||
|
success: true,
|
||||||
|
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
|
||||||
|
data: {
|
||||||
|
leftTable: String(leftTable),
|
||||||
|
rightTable: String(rightTable),
|
||||||
|
relations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "ENTITY_RELATIONS_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,4 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
|
|
|
||||||
|
|
@ -1189,6 +1189,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 +1223,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 +1261,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 +1280,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 +1295,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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 || "*";
|
||||||
|
|
||||||
|
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
||||||
|
if (screenId && screenId > 0) {
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
deletedRecord,
|
deletedRecord,
|
||||||
"delete",
|
"delete",
|
||||||
userId || "system",
|
userId || "system",
|
||||||
recordCompanyCode
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,868 @@
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 처리 서비스
|
||||||
|
*
|
||||||
|
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||||
|
* 엑셀 다운로드/업로드 시 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 다운로드 결과
|
||||||
|
*/
|
||||||
|
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 정보가 있으면 우선 사용
|
||||||
|
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||||
|
let 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. 디테일 레코드들 생성
|
||||||
|
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]);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||||
|
VALUES (${detailPlaceholders.join(", ")}, NOW())`,
|
||||||
|
detailValues
|
||||||
|
);
|
||||||
|
result.detailInserted++;
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||||
|
logger.error(`디테일 행 처리 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
parseInt(flow.flowId),
|
||||||
|
{
|
||||||
|
sourceData: [masterData],
|
||||||
|
dataSourceType: "formData",
|
||||||
|
buttonId: "excel-upload-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: masterData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
|
@ -2201,15 +2201,20 @@ export class MenuCopyService {
|
||||||
"system",
|
"system",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await client.query(
|
const result = await client.query(
|
||||||
`INSERT INTO screen_menu_assignments (
|
`INSERT INTO screen_menu_assignments (
|
||||||
screen_id, menu_objid, company_code, display_order, is_active, created_by
|
screen_id, menu_objid, company_code, display_order, is_active, created_by
|
||||||
) VALUES ${assignmentValues}`,
|
) VALUES ${assignmentValues}
|
||||||
|
ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`,
|
||||||
assignmentParams
|
assignmentParams
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`);
|
logger.info(
|
||||||
|
`✅ 화면-메뉴 할당 완료: ${result.rowCount}개 삽입 (${validAssignments.length - (result.rowCount || 0)}개 중복 무시)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(`📭 화면-메뉴 할당할 항목 없음`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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];
|
|
||||||
|
|
||||||
|
// 🔥 채번 규칙 서비스 동적 import
|
||||||
|
const { numberingRuleService } = await import("./numberingRuleService");
|
||||||
|
|
||||||
|
for (const mapping of fieldMappings) {
|
||||||
|
fields.push(mapping.targetField);
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
// 🔥 값 생성 유형에 따른 처리
|
||||||
|
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
||||||
|
|
||||||
|
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
||||||
|
// 자동 생성 (채번 규칙)
|
||||||
|
const companyCode = context.buttonContext?.companyCode || "*";
|
||||||
|
try {
|
||||||
|
value = await numberingRuleService.allocateCode(
|
||||||
|
mapping.numberingRuleId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
||||||
|
console.error(
|
||||||
|
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
||||||
|
// 고정값
|
||||||
|
value = mapping.staticValue;
|
||||||
|
console.log(
|
||||||
|
` 📌 고정값: ${mapping.targetField} = ${value}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 소스 필드
|
||||||
|
value = data[mapping.sourceField];
|
||||||
console.log(
|
console.log(
|
||||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
` ${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 자동 추가 여부 결정:
|
||||||
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
|
let finalWhereConditions: any[];
|
||||||
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
whereConditions,
|
whereConditions,
|
||||||
data,
|
data,
|
||||||
targetTable
|
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 자동 추가 여부 결정:
|
||||||
|
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||||
|
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||||
|
let finalWhereConditions: any[];
|
||||||
|
if (whereConditions && whereConditions.length > 0) {
|
||||||
|
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||||
|
finalWhereConditions = whereConditions;
|
||||||
|
} else {
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
whereConditions,
|
whereConditions,
|
||||||
data,
|
data,
|
||||||
targetTable
|
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,10 +2771,31 @@ 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];
|
||||||
|
|
||||||
|
// EXISTS 계열 연산자 처리
|
||||||
|
if (
|
||||||
|
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(
|
||||||
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 일반 연산자 처리
|
||||||
let compareValue = condition.value;
|
let compareValue = condition.value;
|
||||||
if (condition.valueType === "field") {
|
if (condition.valueType === "field") {
|
||||||
compareValue = item[condition.value];
|
compareValue = item[condition.value];
|
||||||
|
|
@ -2723,12 +2808,11 @@ export class NodeFlowExecutionService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.evaluateCondition(
|
results.push(
|
||||||
fieldValue,
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
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,9 +2839,29 @@ 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];
|
||||||
|
|
||||||
|
// EXISTS 계열 연산자 처리
|
||||||
|
if (
|
||||||
|
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(
|
||||||
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 일반 연산자 처리
|
||||||
let compareValue = condition.value;
|
let compareValue = condition.value;
|
||||||
if (condition.valueType === "field") {
|
if (condition.valueType === "field") {
|
||||||
compareValue = inputData[condition.value];
|
compareValue = inputData[condition.value];
|
||||||
|
|
@ -2770,12 +2874,11 @@ export class NodeFlowExecutionService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.evaluateCondition(
|
results.push(
|
||||||
fieldValue,
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
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 절 생성
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1398,6 +1398,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();
|
||||||
|
|
|
||||||
|
|
@ -1306,6 +1306,41 @@ 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(
|
||||||
|
|
@ -2261,11 +2296,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 +2332,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 +2422,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 +2482,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 +2505,10 @@ 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 +2711,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 +2767,64 @@ 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) {
|
if (baseJoinConfig) {
|
||||||
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`);
|
||||||
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
}
|
||||||
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
}
|
||||||
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
|
||||||
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
if (baseJoinConfig) {
|
||||||
|
// joinAlias에서 실제 컬럼명 추출
|
||||||
|
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
||||||
|
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
||||||
|
|
||||||
|
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
||||||
|
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
||||||
|
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
||||||
|
let actualColumnName: string;
|
||||||
|
|
||||||
|
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
||||||
|
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
||||||
|
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
||||||
|
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
|
||||||
|
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
||||||
|
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
||||||
|
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
|
||||||
|
} else {
|
||||||
|
// 어느 것도 아니면 원본 사용
|
||||||
|
actualColumnName = originalJoinAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
||||||
|
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
||||||
|
|
||||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
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 +2832,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: " - ", // 기본 구분자
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3684,6 +3806,15 @@ 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",
|
||||||
|
// 필요시 추가
|
||||||
|
];
|
||||||
|
|
||||||
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 +3823,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 +4068,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 +4085,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 +4102,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 +4718,101 @@ 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -360,3 +360,4 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
@ -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" />
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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={
|
|
||||||
<>
|
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새 대시보드 생성 페이지
|
|
||||||
*/
|
|
||||||
export default function DashboardNewPage() {
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
|
|
@ -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={
|
||||||
|
<>
|
||||||
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -127,3 +127,4 @@ export default function ScreenManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
{/* 플로우 목록 */}
|
{/* 플로우 목록 */}
|
||||||
|
|
@ -11,8 +11,8 @@ import { Badge } from "@/components/ui/badge";
|
||||||
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 { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
interface Language {
|
interface Language {
|
||||||
|
|
@ -39,7 +39,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[]>([]);
|
||||||
|
|
@ -64,20 +64,14 @@ export default function MultiLangPage() {
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
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);
|
||||||
|
|
@ -103,17 +97,14 @@ export default function MultiLangPage() {
|
||||||
const response = await apiClient.get("/multilang/keys");
|
const response = await apiClient.get("/multilang/keys");
|
||||||
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 +137,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 +151,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 +180,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 +194,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 +240,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 +275,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 +330,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 +340,6 @@ export default function MultiLangPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 키 저장 실패:", error);
|
|
||||||
alert("언어 키 저장에 실패했습니다.");
|
alert("언어 키 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -397,7 +367,6 @@ export default function MultiLangPage() {
|
||||||
alert("상태 변경 중 오류가 발생했습니다.");
|
alert("상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("키 상태 토글 실패:", error);
|
|
||||||
alert("키 상태 변경 중 오류가 발생했습니다.");
|
alert("키 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -414,7 +383,6 @@ export default function MultiLangPage() {
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 상태 토글 실패:", error);
|
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -453,9 +421,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 +430,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 +445,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,8 +471,6 @@ export default function MultiLangPage() {
|
||||||
initializeData();
|
initializeData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 검색 관련 useEffect 제거 - 실시간 필터링만 사용
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
|
|
@ -552,7 +515,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 +529,6 @@ export default function MultiLangPage() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "설명",
|
header: "설명",
|
||||||
|
|
@ -667,6 +628,8 @@ export default function MultiLangPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="w-full max-w-none px-4 py-8">
|
||||||
<div className="container mx-auto p-2">
|
<div className="container mx-auto p-2">
|
||||||
{/* 탭 네비게이션 */}
|
{/* 탭 네비게이션 */}
|
||||||
<div className="flex space-x-1 border-b">
|
<div className="flex space-x-1 border-b">
|
||||||
|
|
@ -713,7 +676,7 @@ export default function MultiLangPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 다국어 키 관리 탭의 메인 영역 */}
|
{/* 다국어 키 관리 탭 */}
|
||||||
{activeTab === "keys" && (
|
{activeTab === "keys" && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
||||||
{/* 좌측: 언어 키 목록 (7/10) */}
|
{/* 좌측: 언어 키 목록 (7/10) */}
|
||||||
|
|
@ -855,5 +818,8 @@ export default function MultiLangPage() {
|
||||||
languageData={editingLanguage}
|
languageData={editingLanguage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
>
|
||||||
|
{column.referenceTable && column.referenceTable !== "none"
|
||||||
|
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label ||
|
||||||
|
column.referenceTable
|
||||||
|
: "테이블 선택..."}
|
||||||
|
<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>
|
||||||
|
{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">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{option.label}</span>
|
<span className="font-medium">{option.label}</span>
|
||||||
<span className="text-muted-foreground text-xs">{option.value}</span>
|
{option.value !== "none" && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{option.value}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</CommandGroup>
|
||||||
</Select>
|
</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}
|
|
||||||
>
|
>
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||||
</SelectItem>
|
<span className="flex items-center gap-2">
|
||||||
))}
|
|
||||||
{(!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>
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Select>
|
</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}
|
|
||||||
>
|
>
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||||
</SelectItem>
|
<span className="flex items-center gap-2">
|
||||||
))}
|
|
||||||
{(!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>
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Select>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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,11 +243,12 @@ 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="space-y-2 border-b pb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/roles")} className="h-10 w-10">
|
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -340,6 +340,10 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -142,7 +142,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
{/* 편집 버튼 *\/}
|
{/* 편집 버튼 *\/}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
|
router.push(`/admin/screenMng/dashboardList?load=${resolvedParams.dashboardId}`);
|
||||||
}}
|
}}
|
||||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ export default function DashboardListPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin/dashboard"
|
href="/admin/screenMng/dashboardList"
|
||||||
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
➕ 새 대시보드 만들기
|
➕ 새 대시보드 만들기
|
||||||
|
|
@ -185,7 +185,7 @@ export default function DashboardListPage() {
|
||||||
</p>
|
</p>
|
||||||
{!searchTerm && (
|
{!searchTerm && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/dashboard"
|
href="/admin/screenMng/dashboardList"
|
||||||
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
➕ 대시보드 만들기
|
➕ 대시보드 만들기
|
||||||
|
|
@ -251,7 +251,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||||
보기
|
보기
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/dashboard?load=${dashboard.id}`}
|
href={`/admin/screenMng/dashboardList?load=${dashboard.id}`}
|
||||||
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
편집
|
편집
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
|
||||||
export default function MainPage() {
|
export default function MainPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4">
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
{/* Welcome Message */}
|
{/* Welcome Message */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ function ScreenViewPage() {
|
||||||
// 편집 모달 이벤트 리스너 등록
|
// 편집 모달 이벤트 리스너 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenEditModal = (event: CustomEvent) => {
|
const handleOpenEditModal = (event: CustomEvent) => {
|
||||||
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||||
|
|
||||||
setEditModalConfig({
|
setEditModalConfig({
|
||||||
screenId: event.detail.screenId,
|
screenId: event.detail.screenId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import "@/app/globals.css";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "POP - 생산실적관리",
|
||||||
|
description: "생산 현장 실적 관리 시스템",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PopLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopDashboard } from "@/components/pop/dashboard";
|
||||||
|
|
||||||
|
export default function PopPage() {
|
||||||
|
return <PopDashboard />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopApp } from "@/components/pop";
|
||||||
|
|
||||||
|
export default function PopWorkPage() {
|
||||||
|
return <PopApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopApp } from "@/components/pop";
|
||||||
|
|
||||||
|
export default function PopWorkPage() {
|
||||||
|
return <PopApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -388,4 +388,226 @@ select {
|
||||||
border-spacing: 0 !important;
|
border-spacing: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== POP (Production Operation Panel) Styles ===== */
|
||||||
|
|
||||||
|
/* POP 전용 다크 테마 변수 */
|
||||||
|
.pop-dark {
|
||||||
|
/* 배경 색상 */
|
||||||
|
--pop-bg-deepest: 8 12 21;
|
||||||
|
--pop-bg-deep: 10 15 28;
|
||||||
|
--pop-bg-primary: 13 19 35;
|
||||||
|
--pop-bg-secondary: 18 26 47;
|
||||||
|
--pop-bg-tertiary: 25 35 60;
|
||||||
|
--pop-bg-elevated: 32 45 75;
|
||||||
|
|
||||||
|
/* 네온 강조색 */
|
||||||
|
--pop-neon-cyan: 0 212 255;
|
||||||
|
--pop-neon-cyan-bright: 0 240 255;
|
||||||
|
--pop-neon-cyan-dim: 0 150 190;
|
||||||
|
--pop-neon-pink: 255 0 102;
|
||||||
|
--pop-neon-purple: 138 43 226;
|
||||||
|
|
||||||
|
/* 상태 색상 */
|
||||||
|
--pop-success: 0 255 136;
|
||||||
|
--pop-success-dim: 0 180 100;
|
||||||
|
--pop-warning: 255 170 0;
|
||||||
|
--pop-warning-dim: 200 130 0;
|
||||||
|
--pop-danger: 255 51 51;
|
||||||
|
--pop-danger-dim: 200 40 40;
|
||||||
|
|
||||||
|
/* 텍스트 색상 */
|
||||||
|
--pop-text-primary: 255 255 255;
|
||||||
|
--pop-text-secondary: 180 195 220;
|
||||||
|
--pop-text-muted: 100 120 150;
|
||||||
|
|
||||||
|
/* 테두리 색상 */
|
||||||
|
--pop-border: 40 55 85;
|
||||||
|
--pop-border-light: 55 75 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 전용 라이트 테마 변수 */
|
||||||
|
.pop-light {
|
||||||
|
--pop-bg-deepest: 245 247 250;
|
||||||
|
--pop-bg-deep: 240 243 248;
|
||||||
|
--pop-bg-primary: 250 251 253;
|
||||||
|
--pop-bg-secondary: 255 255 255;
|
||||||
|
--pop-bg-tertiary: 245 247 250;
|
||||||
|
--pop-bg-elevated: 235 238 245;
|
||||||
|
|
||||||
|
--pop-neon-cyan: 0 122 204;
|
||||||
|
--pop-neon-cyan-bright: 0 140 230;
|
||||||
|
--pop-neon-cyan-dim: 0 100 170;
|
||||||
|
--pop-neon-pink: 220 38 127;
|
||||||
|
--pop-neon-purple: 118 38 200;
|
||||||
|
|
||||||
|
--pop-success: 22 163 74;
|
||||||
|
--pop-success-dim: 21 128 61;
|
||||||
|
--pop-warning: 245 158 11;
|
||||||
|
--pop-warning-dim: 217 119 6;
|
||||||
|
--pop-danger: 220 38 38;
|
||||||
|
--pop-danger-dim: 185 28 28;
|
||||||
|
|
||||||
|
--pop-text-primary: 15 23 42;
|
||||||
|
--pop-text-secondary: 71 85 105;
|
||||||
|
--pop-text-muted: 148 163 184;
|
||||||
|
|
||||||
|
--pop-border: 226 232 240;
|
||||||
|
--pop-border-light: 203 213 225;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 배경 그리드 패턴 */
|
||||||
|
.pop-bg-pattern::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-light .pop-bg-pattern::before {
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 글로우 효과 */
|
||||||
|
.pop-glow-cyan {
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-cyan-strong {
|
||||||
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-success {
|
||||||
|
box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-warning {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-danger {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 펄스 글로우 애니메이션 */
|
||||||
|
@keyframes pop-pulse-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-animate-pulse-glow {
|
||||||
|
animation: pop-pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 프로그레스 바 샤인 애니메이션 */
|
||||||
|
@keyframes pop-progress-shine {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-progress-shine::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 20px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
|
||||||
|
animation: pop-progress-shine 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 스크롤바 스타일 */
|
||||||
|
.pop-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgb(var(--pop-bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(var(--pop-border-light));
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(var(--pop-neon-cyan-dim));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 스크롤바 숨기기 */
|
||||||
|
.pop-hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Marching Ants Animation (Excel Copy Border) ===== */
|
||||||
|
@keyframes marching-ants-h {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marching-ants-v {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-marching-ants-h {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(var(--primary)) 0,
|
||||||
|
hsl(var(--primary)) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
background-size: 16px 2px;
|
||||||
|
animation: marching-ants-h 0.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-marching-ants-v {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--primary)) 0,
|
||||||
|
hsl(var(--primary)) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
background-size: 2px 16px;
|
||||||
|
animation: marching-ants-v 0.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== End of Global Styles ===== */
|
/* ===== End of Global Styles ===== */
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
|
||||||
import { CompanyToolbar } from "./CompanyToolbar";
|
|
||||||
import { CompanyTable } from "./CompanyTable";
|
|
||||||
import { CompanyFormModal } from "./CompanyFormModal";
|
|
||||||
import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
|
|
||||||
import { DiskUsageSummary } from "./DiskUsageSummary";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회사 관리 메인 컴포넌트
|
|
||||||
* 모든 회사 관리 기능을 통합하여 제공
|
|
||||||
*/
|
|
||||||
export function CompanyManagement() {
|
|
||||||
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="space-y-6">
|
|
||||||
{/* 디스크 사용량 요약 */}
|
|
||||||
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
|
||||||
|
|
||||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
|
||||||
<CompanyToolbar
|
|
||||||
searchFilter={searchFilter}
|
|
||||||
totalCount={companies.length} // 실제 API에서 가져온 데이터 개수 사용
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Building2, Search } from "lucide-react";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { logger } from "@/lib/utils/logger";
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
company_code: string;
|
||||||
|
company_name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanySwitcherProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
isOpen?: boolean; // Dialog 열림 상태 (AppLayout에서 전달)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WACE 관리자 전용: 회사 선택 및 전환 컴포넌트
|
||||||
|
*
|
||||||
|
* - WACE 관리자(company_code = "*", userType = "SUPER_ADMIN")만 표시
|
||||||
|
* - 회사 선택 시 해당 회사로 전환하여 시스템 사용
|
||||||
|
* - JWT 토큰 재발급으로 company_code 변경
|
||||||
|
*/
|
||||||
|
export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProps = {}) {
|
||||||
|
const { user, switchCompany } = useAuth();
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [filteredCompanies, setFilteredCompanies] = useState<Company[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// WACE 관리자 권한 체크 (userType만 확인)
|
||||||
|
const isWaceAdmin = user?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 현재 선택된 회사명 표시
|
||||||
|
const currentCompanyName = React.useMemo(() => {
|
||||||
|
if (!user?.companyCode) return "로딩 중...";
|
||||||
|
|
||||||
|
if (user.companyCode === "*") {
|
||||||
|
return "WACE (최고 관리자)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// companies 배열에서 현재 회사 찾기
|
||||||
|
const currentCompany = companies.find(c => c.company_code === user.companyCode);
|
||||||
|
return currentCompany?.company_name || user.companyCode;
|
||||||
|
}, [user?.companyCode, companies]);
|
||||||
|
|
||||||
|
// 회사 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWaceAdmin && isOpen) {
|
||||||
|
fetchCompanies();
|
||||||
|
}
|
||||||
|
}, [isWaceAdmin, isOpen]);
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchText.trim() === "") {
|
||||||
|
setFilteredCompanies(companies);
|
||||||
|
} else {
|
||||||
|
const filtered = companies.filter(company =>
|
||||||
|
company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
company.company_code.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredCompanies(filtered);
|
||||||
|
}
|
||||||
|
}, [searchText, companies]);
|
||||||
|
|
||||||
|
const fetchCompanies = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiClient.get("/admin/companies/db");
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
// 활성 상태의 회사만 필터링 + company_code="*" 제외 (WACE는 별도 추가)
|
||||||
|
const activeCompanies = response.data.data
|
||||||
|
.filter((c: Company) => c.company_code !== "*") // DB의 "*" 제외
|
||||||
|
.filter((c: Company) => c.status === "active" || !c.status)
|
||||||
|
.sort((a: Company, b: Company) => a.company_name.localeCompare(b.company_name));
|
||||||
|
|
||||||
|
// WACE 복귀 옵션 추가
|
||||||
|
const companiesWithWace: Company[] = [
|
||||||
|
{
|
||||||
|
company_code: "*",
|
||||||
|
company_name: "WACE (최고 관리자)",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
...activeCompanies,
|
||||||
|
];
|
||||||
|
|
||||||
|
setCompanies(companiesWithWace);
|
||||||
|
setFilteredCompanies(companiesWithWace);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("회사 목록 조회 실패", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompanySwitch = async (companyCode: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await switchCompany(companyCode);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.message || "회사 전환에 실패했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("회사 전환 성공", { companyCode });
|
||||||
|
|
||||||
|
// 즉시 페이지 새로고침 (토큰이 이미 저장됨)
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("회사 전환 실패", error);
|
||||||
|
alert(error.message || "회사 전환 중 오류가 발생했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// WACE 관리자가 아니면 렌더링하지 않음
|
||||||
|
if (!isWaceAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 현재 회사 정보 */}
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">현재 관리 회사</p>
|
||||||
|
<p className="text-sm font-semibold">{currentCompanyName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="회사명 또는 코드 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 목록 */}
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : filteredCompanies.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredCompanies.map((company) => (
|
||||||
|
<div
|
||||||
|
key={company.company_code}
|
||||||
|
className={`flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||||
|
company.company_code === user?.companyCode
|
||||||
|
? "bg-accent/50 font-semibold"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCompanySwitch(company.company_code)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{company.company_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{company.company_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{company.company_code === user?.companyCode && (
|
||||||
|
<span className="text-xs text-primary">현재</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
|
|
||||||
// 부서 관리 페이지로 이동
|
// 부서 관리 페이지로 이동
|
||||||
const handleManageDepartments = (company: Company) => {
|
const handleManageDepartments = (company: Company) => {
|
||||||
router.push(`/admin/company/${company.company_code}/departments`);
|
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디스크 사용량 포맷팅 함수
|
// 디스크 사용량 포맷팅 함수
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,288 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
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, BatchExecution } from "@/lib/api/batch";
|
|
||||||
|
|
||||||
export default function MonitoringDashboard() {
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
"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 "./RoleFormModal";
|
|
||||||
import { RoleDeleteModal } from "./RoleDeleteModal";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { companyAPI } from "@/lib/api/company";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 관리 메인 컴포넌트
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 권한 그룹 목록 조회 (회사별)
|
|
||||||
* - 권한 그룹 생성/수정/삭제
|
|
||||||
* - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
|
|
||||||
*/
|
|
||||||
export function RoleManagement() {
|
|
||||||
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/roles/${role.objid}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 관리자가 아니면 접근 제한
|
|
||||||
if (!isAdmin) {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue