; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-12-31 11:01:18 +09:00
commit 0b1dc98e5c
45 changed files with 6337 additions and 4754 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -412,9 +412,9 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
if (userType === "SUPER_ADMIN") {
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
authFilter = "";
unionFilter = "";
} else {

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,124 @@
"use client";
import React from "react";
import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
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 } from "@/lib/api/batch";
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 (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
@ -16,7 +131,170 @@ export default function MonitoringPage() {
</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>
);

View File

@ -1,4 +1,4 @@
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@ -9,6 +9,7 @@ export default function AdminPage() {
return (
<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="mx-auto max-w-7xl space-y-10">
<div className="mb-8 text-center">

View File

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

View File

@ -1,17 +1,18 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { use } from "react";
import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
import {
Dialog,
DialogContent,
@ -32,18 +33,24 @@ import {
import { Button } from "@/components/ui/button";
import { CheckCircle2 } from "lucide-react";
interface DashboardDesignerProps {
dashboardId?: string;
}
/**
*
* /
* URL: /admin/screenMng/dashboardList/[id]
* - id가 "new"
* - id가
*
* :
* - /
* - (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 { refreshMenus } = useMenu();
const [elements, setElements] = useState<DashboardElement[]>([]);

View File

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

View File

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

View File

@ -1,11 +1,167 @@
import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient";
"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로
* - 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">
@ -15,8 +171,287 @@ export default function DashboardListPage() {
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 클라이언트 컴포넌트 */}
<DashboardListClient />
{/* 검색 및 액션 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="relative w-full sm:w-[300px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
<div className="text-muted-foreground text-sm">
<span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span>
</div>
</div>
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 대시보드 목록 */}
{loading ? (
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="bg-card hidden shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16 text-right">
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
</div>
))}
</div>
</div>
))}
</div>
</>
) : error ? (
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
<div className="flex flex-col items-center gap-4 text-center">
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
<AlertCircle className="text-destructive h-8 w-8" />
</div>
<div>
<h3 className="text-destructive mb-2 text-lg font-semibold"> </h3>
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
</div>
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
) : dashboards.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="bg-card hidden shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">
<button
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
</button>
</TableCell>
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{dashboard.createdByName || dashboard.createdBy || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.updatedAt)}
</TableCell>
<TableCell className="h-16 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="gap-2 text-sm"
>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
<Copy className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="text-destructive focus:text-destructive gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{dashboards.map((dashboard) => (
<div
key={dashboard.id}
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<button
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
<h3 className="text-base font-semibold">{dashboard.title}</h3>
</button>
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
</div>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => handleCopy(dashboard)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</>
)}
{/* 페이지네이션 */}
{!loading && dashboards.length > 0 && (
<Pagination
paginationInfo={paginationInfo}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showPageSizeSelector={true}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
{/* 삭제 확인 모달 */}
<DeleteConfirmModal
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="대시보드 삭제"
description={
<>
&quot;{deleteTarget?.title}&quot; ?
<br /> .
</>
}
onConfirm={handleDeleteConfirm}
/>
</div>
</div>
);

View File

@ -51,17 +51,17 @@ export default function DataFlowPage() {
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
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 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">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<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>
</div>
@ -77,12 +77,12 @@ export default function DataFlowPage() {
}
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-2 border-b pb-4">
<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>
{/* 플로우 목록 */}

View File

@ -1,12 +1,823 @@
"use client";
import MultiLang from "@/components/admin/MultiLang";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/common/DataTable";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useAuth } from "@/hooks/useAuth";
import LangKeyModal from "@/components/admin/LangKeyModal";
import LanguageModal from "@/components/admin/LanguageModal";
import { apiClient } from "@/lib/api/client";
interface Language {
langCode: string;
langName: string;
langNative: string;
isActive: string;
}
interface LangKey {
keyId: number;
companyCode: string;
menuName: string;
langKey: string;
description: string;
isActive: string;
}
interface LangText {
textId: number;
keyId: number;
langCode: string;
langText: string;
isActive: string;
}
export default function I18nPage() {
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [languages, setLanguages] = useState<Language[]>([]);
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
const [selectedKey, setSelectedKey] = useState<LangKey | null>(null);
const [langTexts, setLangTexts] = useState<LangText[]>([]);
const [editingTexts, setEditingTexts] = useState<LangText[]>([]);
const [selectedCompany, setSelectedCompany] = useState("all");
const [searchText, setSearchText] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingKey, setEditingKey] = useState<LangKey | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<number>>(new Set());
// 언어 관리 관련 상태
const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
const [editingLanguage, setEditingLanguage] = useState<Language | null>(null);
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
// 회사 목록 조회
const fetchCompanies = async () => {
try {
const response = await apiClient.get("/admin/companies");
const data = response.data;
if (data.success) {
const companyList = data.data.map((company: any) => ({
code: company.company_code,
name: company.company_name,
}));
setCompanies(companyList);
}
} catch (error) {
// console.error("회사 목록 조회 실패:", error);
}
};
// 언어 목록 조회
const fetchLanguages = async () => {
try {
const response = await apiClient.get("/multilang/languages");
const data = response.data;
if (data.success) {
setLanguages(data.data);
}
} catch (error) {
// console.error("언어 목록 조회 실패:", error);
}
};
// 다국어 키 목록 조회
const fetchLangKeys = async () => {
try {
const response = await apiClient.get("/multilang/keys");
const data = response.data;
if (data.success) {
setLangKeys(data.data);
}
} catch (error) {
// console.error("다국어 키 목록 조회 실패:", error);
}
};
// 필터링된 데이터 계산
const getFilteredLangKeys = () => {
let filteredKeys = langKeys;
// 회사 필터링
if (selectedCompany && selectedCompany !== "all") {
filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
}
// 텍스트 검색 필터링
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
filteredKeys = filteredKeys.filter((key) => {
const langKey = (key.langKey || "").toLowerCase();
const description = (key.description || "").toLowerCase();
const menuName = (key.menuName || "").toLowerCase();
const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
return (
langKey.includes(searchLower) ||
description.includes(searchLower) ||
menuName.includes(searchLower) ||
companyName.includes(searchLower)
);
});
}
return filteredKeys;
};
// 선택된 키의 다국어 텍스트 조회
const fetchLangTexts = async (keyId: number) => {
try {
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
const data = response.data;
if (data.success) {
setLangTexts(data.data);
const editingData = data.data.map((text: LangText) => ({ ...text }));
setEditingTexts(editingData);
}
} catch (error) {
// console.error("다국어 텍스트 조회 실패:", error);
}
};
// 언어 키 선택 처리
const handleKeySelect = (key: LangKey) => {
setSelectedKey(key);
fetchLangTexts(key.keyId);
};
// 텍스트 변경 처리
const handleTextChange = (langCode: string, value: string) => {
const newEditingTexts = [...editingTexts];
const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
if (existingIndex >= 0) {
newEditingTexts[existingIndex].langText = value;
} else {
newEditingTexts.push({
textId: 0,
keyId: selectedKey!.keyId,
langCode: langCode,
langText: value,
isActive: "Y",
});
}
setEditingTexts(newEditingTexts);
};
// 텍스트 저장
const handleSave = async () => {
if (!selectedKey) return;
try {
const requestData = {
texts: editingTexts.map((text) => ({
langCode: text.langCode,
langText: text.langText,
isActive: text.isActive || "Y",
createdBy: user?.userId || "system",
updatedBy: user?.userId || "system",
})),
};
const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
const data = response.data;
if (data.success) {
alert("저장되었습니다.");
fetchLangTexts(selectedKey.keyId);
}
} catch (error) {
alert("저장에 실패했습니다.");
}
};
// 언어 키 추가/수정 모달 열기
const handleAddKey = () => {
setEditingKey(null);
setIsModalOpen(true);
};
// 언어 추가/수정 모달 열기
const handleAddLanguage = () => {
setEditingLanguage(null);
setIsLanguageModalOpen(true);
};
// 언어 수정
const handleEditLanguage = (language: Language) => {
setEditingLanguage(language);
setIsLanguageModalOpen(true);
};
// 언어 저장 (추가/수정)
const handleSaveLanguage = async (languageData: any) => {
try {
const requestData = {
...languageData,
createdBy: user?.userId || "admin",
updatedBy: user?.userId || "admin",
};
let response;
if (editingLanguage) {
response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
} else {
response = await apiClient.post("/multilang/languages", requestData);
}
const result = response.data;
if (result.success) {
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
setIsLanguageModalOpen(false);
fetchLanguages();
} else {
alert(`오류: ${result.message}`);
}
} catch (error) {
alert("언어 저장 중 오류가 발생했습니다.");
}
};
// 언어 삭제
const handleDeleteLanguages = async () => {
if (selectedLanguages.size === 0) {
alert("삭제할 언어를 선택해주세요.");
return;
}
if (
!confirm(
`선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
)
) {
return;
}
try {
const deletePromises = Array.from(selectedLanguages).map((langCode) =>
apiClient.delete(`/multilang/languages/${langCode}`),
);
const responses = await Promise.all(deletePromises);
const failedDeletes = responses.filter((response) => !response.data.success);
if (failedDeletes.length === 0) {
alert("선택된 언어가 삭제되었습니다.");
setSelectedLanguages(new Set());
fetchLanguages();
} else {
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
}
} catch (error) {
alert("언어 삭제 중 오류가 발생했습니다.");
}
};
// 언어 선택 체크박스 처리
const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
const newSelected = new Set(selectedLanguages);
if (checked) {
newSelected.add(langCode);
} else {
newSelected.delete(langCode);
}
setSelectedLanguages(newSelected);
};
// 언어 전체 선택/해제
const handleSelectAllLanguages = (checked: boolean) => {
if (checked) {
setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
} else {
setSelectedLanguages(new Set());
}
};
// 언어 키 수정 모달 열기
const handleEditKey = (key: LangKey) => {
setEditingKey(key);
setIsModalOpen(true);
};
// 언어 키 저장 (추가/수정)
const handleSaveKey = async (keyData: any) => {
try {
const requestData = {
...keyData,
createdBy: user?.userId || "admin",
updatedBy: user?.userId || "admin",
};
let response;
if (editingKey) {
response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
} else {
response = await apiClient.post("/multilang/keys", requestData);
}
const data = response.data;
if (data.success) {
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
fetchLangKeys();
setIsModalOpen(false);
} else {
if (data.message && data.message.includes("이미 존재하는 언어키")) {
alert(data.message);
} else {
alert(data.message || "언어 키 저장에 실패했습니다.");
}
}
} catch (error) {
alert("언어 키 저장에 실패했습니다.");
}
};
// 체크박스 선택/해제
const handleCheckboxChange = (keyId: number, checked: boolean) => {
const newSelectedKeys = new Set(selectedKeys);
if (checked) {
newSelectedKeys.add(keyId);
} else {
newSelectedKeys.delete(keyId);
}
setSelectedKeys(newSelectedKeys);
};
// 키 상태 토글
const handleToggleStatus = async (keyId: number) => {
try {
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
const data = response.data;
if (data.success) {
alert(`키가 ${data.data}되었습니다.`);
fetchLangKeys();
} else {
alert("상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
alert("키 상태 변경 중 오류가 발생했습니다.");
}
};
// 언어 상태 토글
const handleToggleLanguageStatus = async (langCode: string) => {
try {
const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
const data = response.data;
if (data.success) {
alert(`언어가 ${data.data}되었습니다.`);
fetchLanguages();
} else {
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
};
// 전체 선택/해제
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
setSelectedKeys(new Set(allKeyIds));
} else {
setSelectedKeys(new Set());
}
};
// 선택된 키들 일괄 삭제
const handleDeleteSelectedKeys = async () => {
if (selectedKeys.size === 0) {
alert("삭제할 키를 선택해주세요.");
return;
}
if (
!confirm(
`선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.`,
)
) {
return;
}
try {
const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
const responses = await Promise.all(deletePromises);
const allSuccess = responses.every((response) => response.data.success);
if (allSuccess) {
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
setSelectedKeys(new Set());
fetchLangKeys();
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
handleCancel();
}
} else {
alert("일부 키 삭제에 실패했습니다.");
}
} catch (error) {
alert("선택된 키 삭제에 실패했습니다.");
}
};
// 개별 키 삭제
const handleDeleteKey = async (keyId: number) => {
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.")) {
return;
}
try {
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
const data = response.data;
if (data.success) {
alert("언어 키가 영구적으로 삭제되었습니다.");
fetchLangKeys();
if (selectedKey && selectedKey.keyId === keyId) {
handleCancel();
}
}
} catch (error) {
alert("언어 키 삭제에 실패했습니다.");
}
};
// 취소 처리
const handleCancel = () => {
setSelectedKey(null);
setLangTexts([]);
setEditingTexts([]);
};
useEffect(() => {
const initializeData = async () => {
setLoading(true);
await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
setLoading(false);
};
initializeData();
}, []);
const columns = [
{
id: "select",
header: () => {
const filteredKeys = getFilteredLangKeys();
return (
<input
type="checkbox"
checked={selectedKeys.size === filteredKeys.length && filteredKeys.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="h-4 w-4"
/>
);
},
cell: ({ row }: any) => (
<input
type="checkbox"
checked={selectedKeys.has(row.original.keyId)}
onChange={(e) => handleCheckboxChange(row.original.keyId, e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
disabled={row.original.isActive === "N"}
/>
),
},
{
accessorKey: "companyCode",
header: "회사",
cell: ({ row }: any) => {
const companyName =
row.original.companyCode === "*"
? "공통"
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
return <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{companyName}</span>;
},
},
{
accessorKey: "menuName",
header: "메뉴명",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
),
},
{
accessorKey: "langKey",
header: "언어 키",
cell: ({ row }: any) => (
<div
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
row.original.isActive === "N" ? "text-gray-400" : ""
}`}
onDoubleClick={() => handleEditKey(row.original)}
>
{row.original.langKey}
</div>
),
},
{
accessorKey: "description",
header: "설명",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.description}</span>
),
},
{
accessorKey: "isActive",
header: "상태",
cell: ({ row }: any) => (
<button
onClick={() => handleToggleStatus(row.original.keyId)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
row.original.isActive === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{row.original.isActive === "Y" ? "활성" : "비활성"}
</button>
),
},
];
// 언어 테이블 컬럼 정의
const languageColumns = [
{
id: "select",
header: () => (
<input
type="checkbox"
checked={selectedLanguages.size === languages.length && languages.length > 0}
onChange={(e) => handleSelectAllLanguages(e.target.checked)}
className="h-4 w-4"
/>
),
cell: ({ row }: any) => (
<input
type="checkbox"
checked={selectedLanguages.has(row.original.langCode)}
onChange={(e) => handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
disabled={row.original.isActive === "N"}
/>
),
},
{
accessorKey: "langCode",
header: "언어 코드",
cell: ({ row }: any) => (
<div
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
row.original.isActive === "N" ? "text-gray-400" : ""
}`}
onDoubleClick={() => handleEditLanguage(row.original)}
>
{row.original.langCode}
</div>
),
},
{
accessorKey: "langName",
header: "언어명 (영문)",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langName}</span>
),
},
{
accessorKey: "langNative",
header: "언어명 (원어)",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langNative}</span>
),
},
{
accessorKey: "isActive",
header: "상태",
cell: ({ row }: any) => (
<button
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
row.original.isActive === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{row.original.isActive === "Y" ? "활성" : "비활성"}
</button>
),
},
];
if (loading) {
return <LoadingSpinner />;
}
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8">
<MultiLang />
<div className="container mx-auto p-2">
{/* 탭 네비게이션 */}
<div className="flex space-x-1 border-b">
<button
onClick={() => setActiveTab("keys")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
<button
onClick={() => setActiveTab("languages")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
</div>
{/* 메인 콘텐츠 영역 */}
<div className="mt-2">
{/* 언어 관리 탭 */}
{activeTab === "languages" && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {languages.length} .</div>
<div className="flex space-x-2">
{selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}>
({selectedLanguages.size})
</Button>
)}
<Button onClick={handleAddLanguage}> </Button>
</div>
</div>
<DataTable data={languages} columns={languageColumns} searchable />
</CardContent>
</Card>
)}
{/* 다국어 키 관리 탭 */}
{activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
{/* 좌측: 언어 키 목록 (7/10) */}
<Card className="lg:col-span-7">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> </CardTitle>
<div className="flex space-x-2">
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
({selectedKeys.size})
</Button>
<Button onClick={handleAddKey}> </Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div>
<Label htmlFor="company"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger>
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="search"></Label>
<Input
placeholder="키명, 설명, 메뉴, 회사로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="flex items-end">
<div className="text-sm text-muted-foreground"> : {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<div className="mb-2 text-sm text-muted-foreground">: {getFilteredLangKeys().length}</div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}
searchable={false}
onRowClick={handleKeySelect}
/>
</div>
</CardContent>
</Card>
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>
{selectedKey ? (
<>
:{" "}
<Badge variant="secondary" className="ml-2">
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
</Badge>
</>
) : (
"다국어 편집"
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedKey ? (
<div>
{/* 스크롤 가능한 텍스트 영역 */}
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
{languages
.filter((lang) => lang.isActive === "Y")
.map((lang) => {
const text = editingTexts.find((t) => t.langCode === lang.langCode);
return (
<div key={lang.langCode} className="flex items-center space-x-4">
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
{lang.langName}
</Badge>
<Input
placeholder={`${lang.langName} 텍스트 입력`}
value={text?.langText || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
className="flex-1"
/>
</div>
);
})}
</div>
{/* 저장 버튼 - 고정 위치 */}
<div className="mt-4 flex space-x-2 border-t pt-4">
<Button onClick={handleSave}></Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
</div>
</div>
) : (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> </div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
{/* 언어 키 추가/수정 모달 */}
<LangKeyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveKey}
keyData={editingKey}
companies={companies}
/>
{/* 언어 추가/수정 모달 */}
<LanguageModal
isOpen={isLanguageModalOpen}
onClose={() => setIsLanguageModalOpen(false)}
onSave={handleSaveLanguage}
languageData={editingLanguage}
/>
</div>
</div>
</div>
);

View File

@ -1,12 +1,115 @@
"use client";
import { useParams } from "next/navigation";
import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company";
/**
*
* 좌측: 부서 , 우측: 부서
*/
export default function DepartmentManagementPage() {
const params = useParams();
const router = useRouter();
const companyCode = params.companyCode as string;
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
return <DepartmentManagement companyCode={companyCode} />;
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드
useEffect(() => {
const loadCompanyInfo = async () => {
const response = await getCompanyList();
if (response.success && response.data) {
const company = response.data.find((c) => c.company_code === companyCode);
if (company) {
setCompanyName(company.company_name);
}
}
};
loadCompanyInfo();
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/userMng/companyList");
};
return (
<div className="space-y-4">
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="bg-border h-6 w-px" />
<div>
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
{/* 탭 네비게이션 (모바일용) */}
<div className="lg:hidden">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="structure"> </TabsTrigger>
<TabsTrigger value="members"> </TabsTrigger>
</TabsList>
<TabsContent value="structure" className="mt-4">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
<TabsContent value="members" className="mt-4">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent>
</Tabs>
</div>
{/* 좌우 레이아웃 (데스크톱) */}
<div className="hidden h-full gap-6 lg:flex">
{/* 좌측: 부서 구조 (20%) */}
<div className="w-[20%] border-r pr-6">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</div>
{/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div>
</div>
</div>
);
}

View File

@ -1,10 +1,56 @@
import { CompanyManagement } from "@/components/admin/CompanyManagement";
"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">
@ -14,8 +60,42 @@ export default function CompanyPage() {
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<CompanyManagement />
{/* 디스크 사용량 요약 */}
<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 버튼 */}

View File

@ -1,12 +1,20 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { use } from "react";
import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
import { useMenu } from "@/contexts/MenuContext";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/roles/[id]
* URL: /admin/userMng/rolesList/[id]
*
* :
* - (Dual List Box)
@ -14,13 +22,324 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
*/
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
const { id } = use(params);
const { id: roleId } = use(params);
const { user: currentUser } = useAuth();
const router = useRouter();
const { refreshMenus } = useMenu();
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [roleGroup, setRoleGroup] = useState<RoleGroup | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 탭 상태
const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
// 멤버 관리 상태
const [availableUsers, setAvailableUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
const [selectedUsers, setSelectedUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
const [isSavingMembers, setIsSavingMembers] = useState(false);
// 메뉴 권한 상태
const [menuPermissions, setMenuPermissions] = useState<any[]>([]);
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
// 데이터 로드
const loadRoleGroup = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await roleAPI.getById(parseInt(roleId, 10));
if (response.success && response.data) {
setRoleGroup(response.data);
} else {
setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("권한 그룹 정보 로드 오류:", err);
setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [roleId]);
// 멤버 목록 로드
const loadMembers = useCallback(async () => {
if (!roleGroup) return;
try {
// 1. 권한 그룹 멤버 조회
const membersResponse = await roleAPI.getMembers(roleGroup.objid);
if (membersResponse.success && membersResponse.data) {
setSelectedUsers(
membersResponse.data.map((member: any) => ({
id: member.userId,
label: member.userName || member.userId,
description: member.deptName,
})),
);
}
// 2. 전체 사용자 목록 조회 (같은 회사)
const userAPI = await import("@/lib/api/user");
console.log("🔍 사용자 목록 조회 요청:", {
companyCode: roleGroup.companyCode,
size: 1000,
});
const usersResponse = await userAPI.userAPI.getList({
companyCode: roleGroup.companyCode,
size: 1000, // 대량 조회
});
console.log("✅ 사용자 목록 응답:", {
success: usersResponse.success,
count: usersResponse.data?.length,
total: usersResponse.total,
});
if (usersResponse.success && usersResponse.data) {
setAvailableUsers(
usersResponse.data.map((user: any) => ({
id: user.userId,
label: user.userName || user.userId,
description: user.deptName,
})),
);
console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
}
} catch (err) {
console.error("멤버 목록 로드 오류:", err);
}
}, [roleGroup]);
// 메뉴 권한 로드
const loadMenuPermissions = useCallback(async () => {
if (!roleGroup) return;
console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
roleGroupId: roleGroup.objid,
roleGroupName: roleGroup.authName,
companyCode: roleGroup.companyCode,
});
try {
const response = await roleAPI.getMenuPermissions(roleGroup.objid);
console.log("✅ [loadMenuPermissions] API 응답", {
success: response.success,
dataCount: response.data?.length,
data: response.data,
});
if (response.success && response.data) {
setMenuPermissions(response.data);
console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
count: response.data.length,
});
} else {
console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
message: response.message,
});
}
} catch (err) {
console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
}
}, [roleGroup]);
useEffect(() => {
loadRoleGroup();
}, [loadRoleGroup]);
useEffect(() => {
if (roleGroup && activeTab === "members") {
loadMembers();
} else if (roleGroup && activeTab === "permissions") {
loadMenuPermissions();
}
}, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
// 멤버 저장 핸들러
const handleSaveMembers = useCallback(async () => {
if (!roleGroup) return;
setIsSavingMembers(true);
try {
// 현재 선택된 사용자 ID 목록
const selectedUserIds = selectedUsers.map((user) => user.id);
// 멤버 업데이트 API 호출
const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
if (response.success) {
alert("멤버가 성공적으로 저장되었습니다.");
loadMembers(); // 새로고침
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
await refreshMenus();
} else {
alert(response.message || "멤버 저장에 실패했습니다.");
}
} catch (err) {
console.error("멤버 저장 오류:", err);
alert("멤버 저장 중 오류가 발생했습니다.");
} finally {
setIsSavingMembers(false);
}
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
// 메뉴 권한 저장 핸들러
const handleSavePermissions = useCallback(async () => {
if (!roleGroup) return;
setIsSavingPermissions(true);
try {
const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
if (response.success) {
alert("메뉴 권한이 성공적으로 저장되었습니다.");
loadMenuPermissions(); // 새로고침
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
await refreshMenus();
} else {
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
}
} catch (err) {
console.error("메뉴 권한 저장 오류:", err);
alert("메뉴 권한 저장 중 오류가 발생했습니다.");
} finally {
setIsSavingPermissions(false);
}
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
if (isLoading) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-12 shadow-sm">
<div className="flex flex-col items-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>
);
}
if (error || !roleGroup) {
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">{error || "권한 그룹을 찾을 수 없습니다."}</p>
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
</Button>
</div>
);
}
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 메인 컨텐츠 */}
<RoleDetailManagement roleId={id} />
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
<p className="text-muted-foreground text-sm">
{roleGroup.authCode} {roleGroup.companyCode}
</p>
</div>
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{roleGroup.status === "active" ? "활성" : "비활성"}
</span>
</div>
</div>
{/* 탭 네비게이션 */}
<div className="flex gap-4 border-b">
<button
onClick={() => setActiveTab("members")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "members"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<Users className="h-4 w-4" />
({selectedUsers.length})
</button>
<button
onClick={() => setActiveTab("permissions")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "permissions"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<MenuIcon className="h-4 w-4" />
({menuPermissions.length})
</button>
</div>
{/* 탭 컨텐츠 */}
<div className="space-y-6">
{activeTab === "members" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
<Save className="h-4 w-4" />
{isSavingMembers ? "저장 중..." : "멤버 저장"}
</Button>
</div>
<DualListBox
availableItems={availableUsers}
selectedItems={selectedUsers}
onSelectionChange={setSelectedUsers}
availableLabel="전체 사용자"
selectedLabel="그룹 멤버"
enableSearch
/>
</>
)}
{activeTab === "permissions" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
<Save className="h-4 w-4" />
{isSavingPermissions ? "저장 중..." : "권한 저장"}
</Button>
</div>
<MenuPermissionsTable
permissions={menuPermissions}
onPermissionsChange={setMenuPermissions}
roleGroup={roleGroup}
/>
</>
)}
</div>
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}

View File

@ -1,6 +1,16 @@
"use client";
import { RoleManagement } from "@/components/admin/RoleManagement";
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";
/**
@ -14,21 +24,336 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* - //
* - (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>
<p className="text-sm text-muted-foreground"> ( )</p>
</div>
{/* 메인 컨텐츠 */}
<RoleManagement />
{/* 에러 메시지 */}
{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 버튼 (모바일/태블릿 전용) */}

View File

@ -1,6 +1,12 @@
"use client";
import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
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";
/**
@ -11,6 +17,119 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* (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">
@ -20,8 +139,39 @@ export default function UserAuthPage() {
<p className="text-muted-foreground text-sm"> . ( )</p>
</div>
{/* 메인 컨텐츠 */}
<UserAuthManagement />
{/* 에러 메시지 */}
{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 버튼 (모바일/태블릿 전용) */}

View File

@ -1,6 +1,12 @@
"use client";
import { UserManagement } from "@/components/admin/UserManagement";
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";
/**
@ -8,8 +14,100 @@ 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">
@ -19,8 +117,70 @@ export default function UserMngPage() {
<p className="text-sm text-muted-foreground"> </p>
</div>
{/* 메인 컨텐츠 */}
<UserManagement />
{/* 툴바 - 검색, 필터, 등록 버튼 */}
<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 버튼 (모바일/태블릿 전용) */}

View File

@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
export default function MainPage() {
return (
<div className="space-y-6 p-4">
{/* 메인 컨텐츠 */}
{/* Welcome Message */}
<Card>
<CardContent className="pt-6">

View File

@ -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>
);
}

View File

@ -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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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>
);
}

View File

@ -1,859 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/common/DataTable";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useAuth } from "@/hooks/useAuth";
import LangKeyModal from "./LangKeyModal";
import LanguageModal from "./LanguageModal";
import { apiClient } from "@/lib/api/client";
interface Language {
langCode: string;
langName: string;
langNative: string;
isActive: string;
}
interface LangKey {
keyId: number;
companyCode: string;
menuName: string;
langKey: string;
description: string;
isActive: string;
}
interface LangText {
textId: number;
keyId: number;
langCode: string;
langText: string;
isActive: string;
}
export default function MultiLangPage() {
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [languages, setLanguages] = useState<Language[]>([]);
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
const [selectedKey, setSelectedKey] = useState<LangKey | null>(null);
const [langTexts, setLangTexts] = useState<LangText[]>([]);
const [editingTexts, setEditingTexts] = useState<LangText[]>([]);
const [selectedCompany, setSelectedCompany] = useState("all");
const [searchText, setSearchText] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingKey, setEditingKey] = useState<LangKey | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<number>>(new Set());
// 언어 관리 관련 상태
const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
const [editingLanguage, setEditingLanguage] = useState<Language | null>(null);
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
// 회사 목록 조회
const fetchCompanies = async () => {
try {
// console.log("회사 목록 조회 시작");
const response = await apiClient.get("/admin/companies");
// console.log("회사 목록 응답 데이터:", response.data);
const data = response.data;
if (data.success) {
const companyList = data.data.map((company: any) => ({
code: company.company_code,
name: company.company_name,
}));
// console.log("변환된 회사 목록:", companyList);
setCompanies(companyList);
} else {
// console.error("회사 목록 조회 실패:", data.message);
}
} catch (error) {
// console.error("회사 목록 조회 실패:", error);
}
};
// 언어 목록 조회
const fetchLanguages = async () => {
try {
const response = await apiClient.get("/multilang/languages");
const data = response.data;
if (data.success) {
setLanguages(data.data);
}
} catch (error) {
// console.error("언어 목록 조회 실패:", error);
}
};
// 다국어 키 목록 조회
const fetchLangKeys = async () => {
try {
const response = await apiClient.get("/multilang/keys");
const data = response.data;
if (data.success) {
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
setLangKeys(data.data);
} else {
// console.error("❌ 키 목록 로드 실패:", data.message);
}
} catch (error) {
// console.error("다국어 키 목록 조회 실패:", error);
}
};
// 필터링된 데이터 계산 - 메뉴관리와 동일한 방식
const getFilteredLangKeys = () => {
let filteredKeys = langKeys;
// 회사 필터링
if (selectedCompany && selectedCompany !== "all") {
filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
}
// 텍스트 검색 필터링
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
filteredKeys = filteredKeys.filter((key) => {
const langKey = (key.langKey || "").toLowerCase();
const description = (key.description || "").toLowerCase();
const menuName = (key.menuName || "").toLowerCase();
const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
return (
langKey.includes(searchLower) ||
description.includes(searchLower) ||
menuName.includes(searchLower) ||
companyName.includes(searchLower)
);
});
}
return filteredKeys;
};
// 선택된 키의 다국어 텍스트 조회
const fetchLangTexts = async (keyId: number) => {
try {
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
const data = response.data;
// console.log("다국어 텍스트 조회 응답:", data);
if (data.success) {
setLangTexts(data.data);
// 편집용 텍스트 초기화
const editingData = data.data.map((text: LangText) => ({ ...text }));
setEditingTexts(editingData);
// console.log("편집용 텍스트 설정:", editingData);
}
} catch (error) {
// console.error("다국어 텍스트 조회 실패:", error);
}
};
// 언어 키 선택 처리
const handleKeySelect = (key: LangKey) => {
// console.log("언어 키 선택:", key);
setSelectedKey(key);
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 newEditingTexts = [...editingTexts];
const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
if (existingIndex >= 0) {
newEditingTexts[existingIndex].langText = value;
} else {
newEditingTexts.push({
textId: 0,
keyId: selectedKey!.keyId,
langCode: langCode,
langText: value,
isActive: "Y",
});
}
setEditingTexts(newEditingTexts);
};
// 텍스트 저장
const handleSave = async () => {
if (!selectedKey) return;
try {
// 백엔드가 기대하는 형식으로 데이터 변환
const requestData = {
texts: editingTexts.map((text) => ({
langCode: text.langCode,
langText: text.langText,
isActive: text.isActive || "Y",
createdBy: user?.userId || "system",
updatedBy: user?.userId || "system",
})),
};
const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
const data = response.data;
if (data.success) {
alert("저장되었습니다.");
// 저장 후 다시 조회
fetchLangTexts(selectedKey.keyId);
}
} catch (error) {
// console.error("텍스트 저장 실패:", error);
alert("저장에 실패했습니다.");
}
};
// 언어 키 추가/수정 모달 열기
const handleAddKey = () => {
setEditingKey(null); // 새 키 추가는 null로 설정
setIsModalOpen(true);
};
// 언어 추가/수정 모달 열기
const handleAddLanguage = () => {
setEditingLanguage(null);
setIsLanguageModalOpen(true);
};
// 언어 수정
const handleEditLanguage = (language: Language) => {
setEditingLanguage(language);
setIsLanguageModalOpen(true);
};
// 언어 저장 (추가/수정)
const handleSaveLanguage = async (languageData: any) => {
try {
const requestData = {
...languageData,
createdBy: user?.userId || "admin",
updatedBy: user?.userId || "admin",
};
let response;
if (editingLanguage) {
response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
} else {
response = await apiClient.post("/multilang/languages", requestData);
}
const result = response.data;
if (result.success) {
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
setIsLanguageModalOpen(false);
fetchLanguages(); // 언어 목록 새로고침
} else {
alert(`오류: ${result.message}`);
}
} catch (error) {
// console.error("언어 저장 중 오류:", error);
alert("언어 저장 중 오류가 발생했습니다.");
}
};
// 언어 삭제
const handleDeleteLanguages = async () => {
if (selectedLanguages.size === 0) {
alert("삭제할 언어를 선택해주세요.");
return;
}
if (
!confirm(
`선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
)
) {
return;
}
try {
const deletePromises = Array.from(selectedLanguages).map((langCode) =>
apiClient.delete(`/multilang/languages/${langCode}`),
);
const responses = await Promise.all(deletePromises);
const failedDeletes = responses.filter((response) => !response.data.success);
if (failedDeletes.length === 0) {
alert("선택된 언어가 삭제되었습니다.");
setSelectedLanguages(new Set());
fetchLanguages(); // 언어 목록 새로고침
} else {
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
}
} catch (error) {
// console.error("언어 삭제 중 오류:", error);
alert("언어 삭제 중 오류가 발생했습니다.");
}
};
// 언어 선택 체크박스 처리
const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
const newSelected = new Set(selectedLanguages);
if (checked) {
newSelected.add(langCode);
} else {
newSelected.delete(langCode);
}
setSelectedLanguages(newSelected);
};
// 언어 전체 선택/해제
const handleSelectAllLanguages = (checked: boolean) => {
if (checked) {
setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
} else {
setSelectedLanguages(new Set());
}
};
// 언어 키 수정 모달 열기
const handleEditKey = (key: LangKey) => {
setEditingKey(key);
setIsModalOpen(true);
};
// 언어 키 저장 (추가/수정)
const handleSaveKey = async (keyData: any) => {
try {
const requestData = {
...keyData,
createdBy: user?.userId || "admin",
updatedBy: user?.userId || "admin",
};
let response;
if (editingKey) {
response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
} else {
response = await apiClient.post("/multilang/keys", requestData);
}
const data = response.data;
if (data.success) {
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
fetchLangKeys(); // 목록 새로고침
setIsModalOpen(false);
} else {
// 중복 체크 오류 메시지 처리
if (data.message && data.message.includes("이미 존재하는 언어키")) {
alert(data.message);
} else {
alert(data.message || "언어 키 저장에 실패했습니다.");
}
}
} catch (error) {
// console.error("언어 키 저장 실패:", error);
alert("언어 키 저장에 실패했습니다.");
}
};
// 체크박스 선택/해제
const handleCheckboxChange = (keyId: number, checked: boolean) => {
const newSelectedKeys = new Set(selectedKeys);
if (checked) {
newSelectedKeys.add(keyId);
} else {
newSelectedKeys.delete(keyId);
}
setSelectedKeys(newSelectedKeys);
};
// 키 상태 토글
const handleToggleStatus = async (keyId: number) => {
try {
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
const data = response.data;
if (data.success) {
alert(`키가 ${data.data}되었습니다.`);
fetchLangKeys();
} else {
alert("상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
// console.error("키 상태 토글 실패:", error);
alert("키 상태 변경 중 오류가 발생했습니다.");
}
};
// 언어 상태 토글
const handleToggleLanguageStatus = async (langCode: string) => {
try {
const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
const data = response.data;
if (data.success) {
alert(`언어가 ${data.data}되었습니다.`);
fetchLanguages();
} else {
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
// console.error("언어 상태 토글 실패:", error);
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
};
// 전체 선택/해제
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
setSelectedKeys(new Set(allKeyIds));
} else {
setSelectedKeys(new Set());
}
};
// 선택된 키들 일괄 삭제
const handleDeleteSelectedKeys = async () => {
if (selectedKeys.size === 0) {
alert("삭제할 키를 선택해주세요.");
return;
}
if (
!confirm(
`선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.`,
)
) {
return;
}
try {
const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
const responses = await Promise.all(deletePromises);
const allSuccess = responses.every((response) => response.data.success);
if (allSuccess) {
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
setSelectedKeys(new Set());
fetchLangKeys(); // 목록 새로고침
// 선택된 키가 삭제된 경우 편집 영역 닫기
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
handleCancel();
}
} else {
alert("일부 키 삭제에 실패했습니다.");
}
} catch (error) {
// console.error("선택된 키 삭제 실패:", error);
alert("선택된 키 삭제에 실패했습니다.");
}
};
// 개별 키 삭제 (기존 함수 유지)
const handleDeleteKey = async (keyId: number) => {
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다.")) {
return;
}
try {
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
const data = response.data;
if (data.success) {
alert("언어 키가 영구적으로 삭제되었습니다.");
fetchLangKeys(); // 목록 새로고침
if (selectedKey && selectedKey.keyId === keyId) {
handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기
}
}
} catch (error) {
// console.error("언어 키 삭제 실패:", error);
alert("언어 키 삭제에 실패했습니다.");
}
};
// 취소 처리
const handleCancel = () => {
setSelectedKey(null);
setLangTexts([]);
setEditingTexts([]);
};
useEffect(() => {
const initializeData = async () => {
setLoading(true);
await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
setLoading(false);
};
initializeData();
}, []);
// 검색 관련 useEffect 제거 - 실시간 필터링만 사용
const columns = [
{
id: "select",
header: () => {
const filteredKeys = getFilteredLangKeys();
return (
<input
type="checkbox"
checked={selectedKeys.size === filteredKeys.length && filteredKeys.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="h-4 w-4"
/>
);
},
cell: ({ row }: any) => (
<input
type="checkbox"
checked={selectedKeys.has(row.original.keyId)}
onChange={(e) => handleCheckboxChange(row.original.keyId, e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
disabled={row.original.isActive === "N"}
/>
),
},
{
accessorKey: "companyCode",
header: "회사",
cell: ({ row }: any) => {
const companyName =
row.original.companyCode === "*"
? "공통"
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
return <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{companyName}</span>;
},
},
{
accessorKey: "menuName",
header: "메뉴명",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
),
},
{
accessorKey: "langKey",
header: "언어 키",
cell: ({ row }: any) => (
<div
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
row.original.isActive === "N" ? "text-gray-400" : ""
}`}
onDoubleClick={() => handleEditKey(row.original)}
>
{row.original.langKey}
</div>
),
},
{
accessorKey: "description",
header: "설명",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.description}</span>
),
},
{
accessorKey: "isActive",
header: "상태",
cell: ({ row }: any) => (
<button
onClick={() => handleToggleStatus(row.original.keyId)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
row.original.isActive === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{row.original.isActive === "Y" ? "활성" : "비활성"}
</button>
),
},
];
// 언어 테이블 컬럼 정의
const languageColumns = [
{
id: "select",
header: () => (
<input
type="checkbox"
checked={selectedLanguages.size === languages.length && languages.length > 0}
onChange={(e) => handleSelectAllLanguages(e.target.checked)}
className="h-4 w-4"
/>
),
cell: ({ row }: any) => (
<input
type="checkbox"
checked={selectedLanguages.has(row.original.langCode)}
onChange={(e) => handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
disabled={row.original.isActive === "N"}
/>
),
},
{
accessorKey: "langCode",
header: "언어 코드",
cell: ({ row }: any) => (
<div
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
row.original.isActive === "N" ? "text-gray-400" : ""
}`}
onDoubleClick={() => handleEditLanguage(row.original)}
>
{row.original.langCode}
</div>
),
},
{
accessorKey: "langName",
header: "언어명 (영문)",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langName}</span>
),
},
{
accessorKey: "langNative",
header: "언어명 (원어)",
cell: ({ row }: any) => (
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langNative}</span>
),
},
{
accessorKey: "isActive",
header: "상태",
cell: ({ row }: any) => (
<button
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
row.original.isActive === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{row.original.isActive === "Y" ? "활성" : "비활성"}
</button>
),
},
];
if (loading) {
return <LoadingSpinner />;
}
return (
<div className="container mx-auto p-2">
{/* 탭 네비게이션 */}
<div className="flex space-x-1 border-b">
<button
onClick={() => setActiveTab("keys")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
<button
onClick={() => setActiveTab("languages")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
</div>
{/* 메인 콘텐츠 영역 */}
<div className="mt-2">
{/* 언어 관리 탭 */}
{activeTab === "languages" && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {languages.length} .</div>
<div className="flex space-x-2">
{selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}>
({selectedLanguages.size})
</Button>
)}
<Button onClick={handleAddLanguage}> </Button>
</div>
</div>
<DataTable data={languages} columns={languageColumns} searchable />
</CardContent>
</Card>
)}
{/* 다국어 키 관리 탭의 메인 영역 */}
{activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
{/* 좌측: 언어 키 목록 (7/10) */}
<Card className="lg:col-span-7">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> </CardTitle>
<div className="flex space-x-2">
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
({selectedKeys.size})
</Button>
<Button onClick={handleAddKey}> </Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div>
<Label htmlFor="company"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger>
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="search"></Label>
<Input
placeholder="키명, 설명, 메뉴, 회사로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="flex items-end">
<div className="text-sm text-muted-foreground"> : {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<div className="mb-2 text-sm text-muted-foreground">: {getFilteredLangKeys().length}</div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}
searchable={false}
onRowClick={handleKeySelect}
/>
</div>
</CardContent>
</Card>
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>
{selectedKey ? (
<>
:{" "}
<Badge variant="secondary" className="ml-2">
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
</Badge>
</>
) : (
"다국어 편집"
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedKey ? (
<div>
{/* 스크롤 가능한 텍스트 영역 */}
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
{languages
.filter((lang) => lang.isActive === "Y")
.map((lang) => {
const text = editingTexts.find((t) => t.langCode === lang.langCode);
return (
<div key={lang.langCode} className="flex items-center space-x-4">
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
{lang.langName}
</Badge>
<Input
placeholder={`${lang.langName} 텍스트 입력`}
value={text?.langText || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
className="flex-1"
/>
</div>
);
})}
</div>
{/* 저장 버튼 - 고정 위치 */}
<div className="mt-4 flex space-x-2 border-t pt-4">
<Button onClick={handleSave}></Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
</div>
</div>
) : (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="text-center">
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> </div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
{/* 언어 키 추가/수정 모달 */}
<LangKeyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveKey}
keyData={editingKey}
companies={companies}
/>
{/* 언어 추가/수정 모달 */}
<LanguageModal
isOpen={isLanguageModalOpen}
onClose={() => setIsLanguageModalOpen(false)}
onSave={handleSaveLanguage}
languageData={editingLanguage}
/>
</div>
);
}

View File

@ -1,345 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "./MenuPermissionsTable";
import { useMenu } from "@/contexts/MenuContext";
interface RoleDetailManagementProps {
roleId: string;
}
/**
*
*
* :
* -
* - (Dual List Box)
* - (CRUD )
*/
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
const { user: currentUser } = useAuth();
const router = useRouter();
const { refreshMenus } = useMenu();
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [roleGroup, setRoleGroup] = useState<RoleGroup | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 탭 상태
const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
// 멤버 관리 상태
const [availableUsers, setAvailableUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
const [selectedUsers, setSelectedUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
const [isSavingMembers, setIsSavingMembers] = useState(false);
// 메뉴 권한 상태
const [menuPermissions, setMenuPermissions] = useState<any[]>([]);
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
// 데이터 로드
const loadRoleGroup = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await roleAPI.getById(parseInt(roleId, 10));
if (response.success && response.data) {
setRoleGroup(response.data);
} else {
setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("권한 그룹 정보 로드 오류:", err);
setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [roleId]);
// 멤버 목록 로드
const loadMembers = useCallback(async () => {
if (!roleGroup) return;
try {
// 1. 권한 그룹 멤버 조회
const membersResponse = await roleAPI.getMembers(roleGroup.objid);
if (membersResponse.success && membersResponse.data) {
setSelectedUsers(
membersResponse.data.map((member: any) => ({
id: member.userId,
label: member.userName || member.userId,
description: member.deptName,
})),
);
}
// 2. 전체 사용자 목록 조회 (같은 회사)
const userAPI = await import("@/lib/api/user");
console.log("🔍 사용자 목록 조회 요청:", {
companyCode: roleGroup.companyCode,
size: 1000,
});
const usersResponse = await userAPI.userAPI.getList({
companyCode: roleGroup.companyCode,
size: 1000, // 대량 조회
});
console.log("✅ 사용자 목록 응답:", {
success: usersResponse.success,
count: usersResponse.data?.length,
total: usersResponse.total,
});
if (usersResponse.success && usersResponse.data) {
setAvailableUsers(
usersResponse.data.map((user: any) => ({
id: user.userId,
label: user.userName || user.userId,
description: user.deptName,
})),
);
console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
}
} catch (err) {
console.error("멤버 목록 로드 오류:", err);
}
}, [roleGroup]);
// 메뉴 권한 로드
const loadMenuPermissions = useCallback(async () => {
if (!roleGroup) return;
console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
roleGroupId: roleGroup.objid,
roleGroupName: roleGroup.authName,
companyCode: roleGroup.companyCode,
});
try {
const response = await roleAPI.getMenuPermissions(roleGroup.objid);
console.log("✅ [loadMenuPermissions] API 응답", {
success: response.success,
dataCount: response.data?.length,
data: response.data,
});
if (response.success && response.data) {
setMenuPermissions(response.data);
console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
count: response.data.length,
});
} else {
console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
message: response.message,
});
}
} catch (err) {
console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
}
}, [roleGroup]);
useEffect(() => {
loadRoleGroup();
}, [loadRoleGroup]);
useEffect(() => {
if (roleGroup && activeTab === "members") {
loadMembers();
} else if (roleGroup && activeTab === "permissions") {
loadMenuPermissions();
}
}, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
// 멤버 저장 핸들러
const handleSaveMembers = useCallback(async () => {
if (!roleGroup) return;
setIsSavingMembers(true);
try {
// 현재 선택된 사용자 ID 목록
const selectedUserIds = selectedUsers.map((user) => user.id);
// 멤버 업데이트 API 호출
const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
if (response.success) {
alert("멤버가 성공적으로 저장되었습니다.");
loadMembers(); // 새로고침
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
await refreshMenus();
} else {
alert(response.message || "멤버 저장에 실패했습니다.");
}
} catch (err) {
console.error("멤버 저장 오류:", err);
alert("멤버 저장 중 오류가 발생했습니다.");
} finally {
setIsSavingMembers(false);
}
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
// 메뉴 권한 저장 핸들러
const handleSavePermissions = useCallback(async () => {
if (!roleGroup) return;
setIsSavingPermissions(true);
try {
const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
if (response.success) {
alert("메뉴 권한이 성공적으로 저장되었습니다.");
loadMenuPermissions(); // 새로고침
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
await refreshMenus();
} else {
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
}
} catch (err) {
console.error("메뉴 권한 저장 오류:", err);
alert("메뉴 권한 저장 중 오류가 발생했습니다.");
} finally {
setIsSavingPermissions(false);
}
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
if (isLoading) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-12 shadow-sm">
<div className="flex flex-col items-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>
);
}
if (error || !roleGroup) {
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">{error || "권한 그룹을 찾을 수 없습니다."}</p>
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
</Button>
</div>
);
}
return (
<>
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
<p className="text-muted-foreground text-sm">
{roleGroup.authCode} {roleGroup.companyCode}
</p>
</div>
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{roleGroup.status === "active" ? "활성" : "비활성"}
</span>
</div>
</div>
{/* 탭 네비게이션 */}
<div className="flex gap-4 border-b">
<button
onClick={() => setActiveTab("members")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "members"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<Users className="h-4 w-4" />
({selectedUsers.length})
</button>
<button
onClick={() => setActiveTab("permissions")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "permissions"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<MenuIcon className="h-4 w-4" />
({menuPermissions.length})
</button>
</div>
{/* 탭 컨텐츠 */}
<div className="space-y-6">
{activeTab === "members" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
<Save className="h-4 w-4" />
{isSavingMembers ? "저장 중..." : "멤버 저장"}
</Button>
</div>
<DualListBox
availableItems={availableUsers}
selectedItems={selectedUsers}
onSelectionChange={setSelectedUsers}
availableLabel="전체 사용자"
selectedLabel="그룹 멤버"
enableSearch
/>
</>
)}
{activeTab === "permissions" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
<Save className="h-4 w-4" />
{isSavingPermissions ? "저장 중..." : "권한 저장"}
</Button>
</div>
<MenuPermissionsTable
permissions={menuPermissions}
onPermissionsChange={setMenuPermissions}
roleGroup={roleGroup}
/>
</>
)}
</div>
</>
);
}

View File

@ -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/userMng/rolesList/${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}
/>
</>
);
}

View File

@ -1,157 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { UserAuthTable } from "./UserAuthTable";
import { UserAuthEditModal } from "./UserAuthEditModal";
import { userAPI } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
*
*
* :
* - ( )
* -
* -
*/
export function UserAuthManagement() {
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-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 (
<div className="space-y-6">
{/* 에러 메시지 */}
{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>
);
}

View File

@ -1,176 +0,0 @@
"use client";
import { useState } from "react";
import { useUserManagement } from "@/hooks/useUserManagement";
import { UserToolbar } from "./UserToolbar";
import { UserTable } from "./UserTable";
import { Pagination } from "@/components/common/Pagination";
import { UserPasswordResetModal } from "./UserPasswordResetModal";
import { UserFormModal } from "./UserFormModal";
/**
*
* - Spring + JSP REST API
* -
*/
export function UserManagement() {
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 = () => {
// refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
handlePasswordResetClose();
};
return (
<div className="space-y-6">
{/* 툴바 - 검색, 필터, 등록 버튼 */}
<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>
);
}

View File

@ -1,117 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "./DepartmentStructure";
import { DepartmentMembers } from "./DepartmentMembers";
import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company";
interface DepartmentManagementProps {
companyCode: string;
}
/**
*
* 좌측: 부서 , 우측: 부서
*/
export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
const router = useRouter();
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드
useEffect(() => {
const loadCompanyInfo = async () => {
const response = await getCompanyList();
if (response.success && response.data) {
const company = response.data.find((c) => c.company_code === companyCode);
if (company) {
setCompanyName(company.company_name);
}
}
};
loadCompanyInfo();
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/userMng/companyList");
};
return (
<div className="space-y-4">
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="bg-border h-6 w-px" />
<div>
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
{/* 탭 네비게이션 (모바일용) */}
<div className="lg:hidden">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="structure"> </TabsTrigger>
<TabsTrigger value="members"> </TabsTrigger>
</TabsList>
<TabsContent value="structure" className="mt-4">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
<TabsContent value="members" className="mt-4">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent>
</Tabs>
</div>
{/* 좌우 레이아웃 (데스크톱) */}
<div className="hidden h-full gap-6 lg:flex">
{/* 좌측: 부서 구조 (20%) */}
<div className="w-[20%] border-r pr-6">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</div>
{/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div>
</div>
</div>
);
}

View File

@ -17,12 +17,14 @@ import {
UserCheck,
LogOut,
User,
Building2,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { ProfileModal } from "./ProfileModal";
import { Logo } from "./Logo";
@ -35,6 +37,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
// useAuth의 UserInfo 타입을 확장
interface ExtendedUserInfo {
@ -206,11 +216,38 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { user, logout, refreshUserData } = useAuth();
const { user, logout, refreshUserData, switchCompany } = useAuth();
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
const [isMobile, setIsMobile] = useState(false);
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
// 현재 회사명 조회 (SUPER_ADMIN 전용)
useEffect(() => {
const fetchCurrentCompanyName = async () => {
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
const companyCode = (user as ExtendedUserInfo)?.companyCode;
if (companyCode === "*") {
setCurrentCompanyName("WACE (최고 관리자)");
} else if (companyCode) {
try {
const response = await apiClient.get("/admin/companies/db");
if (response.data.success) {
const company = response.data.data.find((c: any) => c.company_code === companyCode);
setCurrentCompanyName(company?.company_name || companyCode);
}
} catch (error) {
setCurrentCompanyName(companyCode);
}
}
}
};
fetchCurrentCompanyName();
}, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
// 화면 크기 감지 및 사이드바 초기 상태 설정
useEffect(() => {
@ -333,11 +370,32 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 모드 전환 핸들러
const handleModeSwitch = () => {
const handleModeSwitch = async () => {
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
router.push("/admin");
// 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
// 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
if (currentCompanyCode !== "*") {
const result = await switchCompany("*");
if (result.success) {
// 페이지 새로고침 (관리자 페이지로 이동)
window.location.href = "/admin";
} else {
toast.error("WACE로 전환 실패");
}
} else {
// 이미 WACE면 바로 관리자 페이지로 이동
router.push("/admin");
}
} else {
// 일반 관리자는 바로 관리자 페이지로 이동
router.push("/admin");
}
}
};
@ -498,11 +556,27 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
)}
{/* WACE 관리자: 현재 관리 회사 표시 */}
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<div className="mx-3 mt-3 rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-[10px] text-muted-foreground"> </p>
<p className="truncate text-sm font-semibold" title={currentCompanyName || "로딩 중..."}>
{currentCompanyName || "로딩 중..."}
</p>
</div>
</div>
</div>
)}
{/* Admin/User 모드 전환 버튼 (관리자만) */}
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "admin") && (
<div className="border-b border-slate-200 p-3">
<div className="space-y-2 border-b border-slate-200 p-3">
{/* 관리자/사용자 메뉴 전환 */}
<Button
onClick={handleModeSwitch}
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
@ -523,6 +597,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</>
)}
</Button>
{/* WACE 관리자 전용: 회사 선택 버튼 */}
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<Button
onClick={() => { console.log("🔴 회사 선택 버튼 클릭!"); setShowCompanySwitcher(true); }}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-medium text-purple-700 transition-colors duration-200 hover:cursor-pointer hover:bg-purple-100"
>
<Building2 className="h-4 w-4" />
</Button>
)}
</div>
)}
@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) {
onSave={saveProfile}
onAlertClose={closeAlert}
/>
{/* 회사 전환 모달 (WACE 관리자 전용) */}
<Dialog open={showCompanySwitcher} onOpenChange={setShowCompanySwitcher}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="mt-4">
<CompanySwitcher onClose={() => setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -3081,6 +3081,79 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
)}
{/* 🆕 행 선택 시에만 활성화 설정 */}
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<p className="text-xs text-muted-foreground">
.
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Switch
checked={component.componentConfig?.action?.requireRowSelection || false}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.requireRowSelection", checked);
}}
/>
</div>
{component.componentConfig?.action?.requireRowSelection && (
<div className="space-y-3 pl-4 border-l-2 border-primary/20">
<div>
<Label htmlFor="row-selection-source"> </Label>
<Select
value={component.componentConfig?.action?.rowSelectionSource || "auto"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.rowSelectionSource", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="tableList"> </SelectItem>
<SelectItem value="splitPanelLeft"> </SelectItem>
<SelectItem value="flowWidget"> </SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
감지: 테이블, ,
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-xs text-muted-foreground">
(기본: 1개 )
</p>
</div>
<Switch
checked={component.componentConfig?.action?.allowMultiRowSelection ?? true}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked);
}}
/>
</div>
{!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && (
<div className="rounded-md bg-yellow-50 p-2 dark:bg-yellow-950/20">
<p className="text-xs text-yellow-800 dark:text-yellow-200">
1 .
</p>
</div>
)}
</div>
)}
</div>
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import type { MenuItem } from "@/lib/api/menu";
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth"; // user 정보 가져오기
interface MenuContextType {
adminMenus: MenuItem[];
@ -18,6 +19,7 @@ export function MenuProvider({ children }: { children: ReactNode }) {
const [adminMenus, setAdminMenus] = useState<MenuItem[]>([]);
const [userMenus, setUserMenus] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useAuth(); // user 정보 가져오기
const convertMenuData = (data: any[]): MenuItem[] => {
return data.map((item) => ({
@ -96,8 +98,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
};
useEffect(() => {
// user.companyCode가 변경되면 메뉴 다시 로드
// console.log("🔄 MenuContext: user.companyCode 변경 감지, 메뉴 재로드", user?.companyCode);
loadMenus();
}, []); // 초기 로드만
}, [user?.companyCode]); // companyCode 변경 시 재로드
return (
<MenuContext.Provider value={{ adminMenus, userMenus, loading, refreshMenus }}>{children}</MenuContext.Provider>

View File

@ -331,6 +331,61 @@ export const useAuth = () => {
[apiCall, refreshUserData],
);
/**
* (WACE )
*/
const switchCompany = useCallback(
async (companyCode: string): Promise<{ success: boolean; message: string }> => {
try {
// console.log("🔵 useAuth.switchCompany 시작:", companyCode);
setLoading(true);
setError(null);
// console.log("🔵 API 호출: POST /auth/switch-company");
const response = await apiCall<any>("POST", "/auth/switch-company", {
companyCode,
});
// console.log("🔵 API 응답:", response);
if (response.success && response.data?.token) {
// console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
// 새로운 JWT 토큰 저장
TokenManager.setToken(response.data.token);
// console.log("🔵 토큰 저장 완료");
// refreshUserData 호출하지 않고 바로 성공 반환
// (페이지 새로고침 시 자동으로 갱신됨)
// console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
return {
success: true,
message: response.message || "회사 전환에 성공했습니다.",
};
} else {
// console.error("🔵 API 응답 실패:", response);
return {
success: false,
message: response.message || "회사 전환에 실패했습니다.",
};
}
} catch (error: any) {
// console.error("🔵 switchCompany 에러:", error);
const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
setError(errorMessage);
return {
success: false,
message: errorMessage,
};
} finally {
setLoading(false);
// console.log("🔵 switchCompany 완료");
}
},
[apiCall]
);
/**
*
*/
@ -493,6 +548,7 @@ export const useAuth = () => {
// 함수
login,
logout,
switchCompany, // 🆕 회사 전환 함수
checkMenuAuth,
refreshUserData,

View File

@ -85,9 +85,9 @@ export const menuApi = {
return response.data;
},
// 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
// 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시, 회사별 필터링)
getUserMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } });
const response = await apiClient.get("/admin/user-menus");
return response.data;
},

View File

@ -120,3 +120,41 @@ export interface NodeExecutionSummary {
duration?: number;
error?: string;
}
/**
*
*/
export interface FlowSourceTableInfo {
sourceTable: string | null;
sourceNodeType: string | null;
sourceNodeId?: string;
displayName?: string;
message?: string;
}
/**
*
* (tableSource, externalDBSource)
*/
export async function getFlowSourceTable(flowId: number): Promise<FlowSourceTableInfo> {
try {
const response = await apiClient.get<ApiResponse<FlowSourceTableInfo>>(
`/dataflow/node-flows/${flowId}/source-table`,
);
if (response.data.success && response.data.data) {
return response.data.data;
}
return {
sourceTable: null,
sourceNodeType: null,
message: response.data.message || "소스 테이블 정보를 가져올 수 없습니다.",
};
} catch (error) {
console.error("플로우 소스 테이블 조회 실패:", error);
return {
sourceTable: null,
sourceNodeType: null,
message: "API 호출 중 오류가 발생했습니다.",
};
}
}

View File

@ -296,6 +296,145 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return false;
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
// modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => {
const actionConfig = component.componentConfig?.action;
if (!actionConfig?.requireRowSelection) return;
// 동적 import로 modalDataStore 구독
let unsubscribe: (() => void) | undefined;
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
// 초기값 설정
setModalStoreData(useModalDataStore.getState().dataRegistry);
// 상태 변경 구독
unsubscribe = useModalDataStore.subscribe((state) => {
setModalStoreData(state.dataRegistry);
});
});
return () => {
unsubscribe?.();
};
}, [component.componentConfig?.action?.requireRowSelection]);
// 🆕 행 선택 기반 비활성화 조건 계산
const isRowSelectionDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
// requireRowSelection이 활성화되어 있지 않으면 비활성화하지 않음
if (!actionConfig?.requireRowSelection) {
return false;
}
const rowSelectionSource = actionConfig.rowSelectionSource || "auto";
const allowMultiRowSelection = actionConfig.allowMultiRowSelection ?? true;
// 선택된 데이터 확인
let hasSelection = false;
let selectionCount = 0;
let selectionSource = "";
// 1. 자동 감지 모드 또는 테이블 리스트 모드
if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") {
// TableList에서 선택된 행 확인 (props로 전달됨)
if (selectedRowsData && selectedRowsData.length > 0) {
hasSelection = true;
selectionCount = selectedRowsData.length;
selectionSource = "tableList (selectedRowsData)";
}
// 또는 selectedRows prop 확인
else if (selectedRows && selectedRows.length > 0) {
hasSelection = true;
selectionCount = selectedRows.length;
selectionSource = "tableList (selectedRows)";
}
}
// 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
// SplitPanelContext에서 확인
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
if (!hasSelection) {
hasSelection = true;
selectionCount = 1;
selectionSource = "splitPanelLeft (context)";
}
}
// 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장)
if (!hasSelection && Object.keys(modalStoreData).length > 0) {
// modalDataStore에서 데이터가 있는지 확인
for (const [sourceId, items] of Object.entries(modalStoreData)) {
if (items && items.length > 0) {
hasSelection = true;
selectionCount = items.length;
selectionSource = `modalDataStore (${sourceId})`;
break;
}
}
}
}
// 3. 플로우 위젯 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") {
// 플로우 위젯 선택 데이터 확인
if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) {
hasSelection = true;
selectionCount = flowSelectedData.length;
selectionSource = "flowWidget";
}
}
// 디버깅 로그
console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, {
rowSelectionSource,
hasSelection,
selectionCount,
selectionSource,
hasSplitPanelContext: !!splitPanelContext,
selectedLeftData: splitPanelContext?.selectedLeftData,
selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length,
modalStoreDataKeys: Object.keys(modalStoreData),
});
// 선택된 데이터가 없으면 비활성화
if (!hasSelection) {
console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label);
return true;
}
// 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함
if (!allowMultiRowSelection && selectionCount !== 1) {
console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, {
selectionCount,
allowMultiRowSelection,
});
return true;
}
console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
selectionCount,
selectionSource,
});
return false;
}, [
component.componentConfig?.action,
component.label,
selectedRows,
selectedRowsData,
splitPanelContext?.selectedLeftData,
flowSelectedData,
splitPanelContext,
modalStoreData,
]);
// 확인 다이얼로그 상태
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<{
@ -832,7 +971,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
// 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음
// (다른 화면에서 선택한 데이터가 남아있을 수 있으므로)
const shouldFetchFromModalDataStore =
processedConfig.action.type !== "modal" &&
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
effectiveTableName;
if (shouldFetchFromModalDataStore) {
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;
@ -860,12 +1006,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return;
}
// 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단
// (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지)
if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) {
toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요.");
return;
}
// 🔧 모달 액션 시 선택 데이터 경고 제거
// 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나,
// 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함.
// 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨.
// 수정(edit) 액션 검증
if (processedConfig.action.type === "edit") {
@ -1088,17 +1232,26 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화)
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading;
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지
const userStyle = component.style
? Object.fromEntries(
Object.entries(component.style).filter(
([key]) => !["width", "height", "background", "backgroundColor"].includes(key)
)
)
: {};
const buttonElementStyle: React.CSSProperties = {
width: "100%",
height: "100%",
minHeight: "40px",
border: "none",
borderRadius: "0.5rem",
background: finalDisabled ? "#e5e7eb" : buttonColor,
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
color: finalDisabled ? "#9ca3af" : "white",
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
@ -1114,10 +1267,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
margin: "0",
lineHeight: "1.25",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
: {}),
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외)
...userStyle,
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";

View File

@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="border-border flex flex-shrink-0 flex-col border-r"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader
<CardHeader
className="flex-shrink-0 border-b"
style={{
style={{
height: componentConfig.leftPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.leftPanel?.panelHeaderHeight || 48,
padding: '0 1rem',
display: 'flex',
alignItems: 'center'
padding: "0 1rem",
display: "flex",
alignItems: "center",
}}
>
<div className="flex w-full items-center justify-between">
@ -2521,14 +2521,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="flex flex-shrink-0 flex-col"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader
<CardHeader
className="flex-shrink-0 border-b"
style={{
style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
padding: '0 1rem',
display: 'flex',
alignItems: 'center'
padding: "0 1rem",
display: "flex",
alignItems: "center",
}}
>
<div className="flex w-full items-center justify-between">

View File

@ -963,6 +963,13 @@ export function UniversalFormModalComponent({
}
}
// 별도 테이블에 저장해야 하는 테이블 섹션 목록
const tableSectionsForSeparateTable = config.sections.filter(
(s) => s.type === "table" &&
s.tableConfig?.saveConfig?.targetTable &&
s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName
);
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
@ -971,6 +978,12 @@ export function UniversalFormModalComponent({
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
);
console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id));
console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id));
console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
@ -1050,35 +1063,51 @@ export function UniversalFormModalComponent({
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id;
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
// 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
if (sectionSaveModes && sectionSaveModes.length > 0) {
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
// 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리
if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) {
for (const optGroup of otherSection.optionalFieldGroups) {
if (optGroup.fields) {
for (const field of optGroup.fields) {
// 선택적 필드 그룹은 기본적으로 common 저장
if (formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
}
}
}
console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData));
for (const item of sectionData) {
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item };
@ -1091,15 +1120,26 @@ export function UniversalFormModalComponent({
}
}
// _sourceData 등 내부 메타데이터 제거
Object.keys(itemToSave).forEach((key) => {
if (key.startsWith("_")) {
delete itemToSave[key];
}
});
// 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
}
await apiClient.post(
const saveResponse = await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave
);
if (!saveResponse.data?.success) {
throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
}
}
}
}

View File

@ -2928,54 +2928,74 @@ export function TableSectionSettingsModal({
{/* UI 설정 */}
<div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium">UI </h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={tableConfig.uiConfig?.addButtonType || "search"}
onValueChange={(value) => updateUiConfig({ addButtonType: value as "search" | "addRow" })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="버튼 동작 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
<div className="flex flex-col">
<span> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</div>
</SelectItem>
<SelectItem value="addRow">
<div className="flex flex-col">
<span> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
{/* 버튼 표시 설정 */}
<div className="space-y-2 p-3 bg-muted/30 rounded-lg">
<Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showSearchButton ?? true}
onCheckedChange={(checked) => updateUiConfig({ showSearchButton: checked })}
className="scale-75"
/>
<div>
<span className="text-xs font-medium"> </span>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showAddRowButton ?? false}
onCheckedChange={(checked) => updateUiConfig({ showAddRowButton: checked })}
className="scale-75"
/>
<div>
<span className="text-xs font-medium"> </span>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 검색 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addButtonText || ""}
onChange={(e) => updateUiConfig({ addButtonText: e.target.value })}
placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"}
value={tableConfig.uiConfig?.searchButtonText || ""}
onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })}
placeholder="품목 검색"
className="h-8 text-xs mt-1"
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
</div>
{/* 행 추가 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addRowButtonText || ""}
onChange={(e) => updateUiConfig({ addRowButtonText: e.target.value })}
placeholder="직접 입력"
className="h-8 text-xs mt-1"
disabled={!tableConfig.uiConfig?.showAddRowButton}
/>
</div>
{/* 모달 제목 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.modalTitle || ""}
onChange={(e) => updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs mt-1"
disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
{tableConfig.uiConfig?.addButtonType === "addRow" && (
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
)}
</div>
{/* 테이블 최대 높이 */}
<div>
<Label className="text-xs"> </Label>
<Input
@ -2985,13 +3005,14 @@ export function TableSectionSettingsModal({
className="h-8 text-xs mt-1"
/>
</div>
{/* 다중 선택 허용 */}
<div className="flex items-end">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={tableConfig.uiConfig?.multiSelect ?? true}
onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })}
className="scale-75"
disabled={tableConfig.uiConfig?.addButtonType === "addRow"}
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
<span> </span>
</label>

View File

@ -253,15 +253,19 @@ export interface TableSectionConfig {
// 6. UI 설정
uiConfig?: {
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
// 추가 버튼 타입
// - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택
// - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력
// 버튼 표시 설정 (동시 표시 가능)
showSearchButton?: boolean; // 검색 버튼 표시 (기본: true)
showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false)
searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색")
addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력")
// 레거시 호환용 (deprecated)
addButtonType?: "search" | "addRow";
addButtonText?: string;
};
// 7. 조건부 테이블 설정 (고급)

View File

@ -1491,6 +1491,7 @@ export class ButtonActionExecutor {
* 🆕 Universal Form Modal
* _폼_모달 + _tableSection_
* 모드: INSERT/UPDATE/DELETE
* 🆕 (targetTable)
*/
private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig,
@ -1514,7 +1515,66 @@ export class ButtonActionExecutor {
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
const modalData = formData[universalFormModalKey];
// 🆕 universal-form-modal 컴포넌트 설정 가져오기
// 1. componentConfigs에서 컴포넌트 ID로 찾기
// 2. allComponents에서 columnName으로 찾기
// 3. 화면 레이아웃 API에서 가져오기
let modalComponentConfig = context.componentConfigs?.[universalFormModalKey];
// componentConfigs에서 직접 찾지 못한 경우, allComponents에서 columnName으로 찾기
if (!modalComponentConfig && context.allComponents) {
const modalComponent = context.allComponents.find(
(comp: any) =>
comp.columnName === universalFormModalKey || comp.properties?.columnName === universalFormModalKey,
);
if (modalComponent) {
modalComponentConfig = modalComponent.componentConfig || modalComponent.properties?.componentConfig;
console.log("🎯 [handleUniversalFormModalTableSectionSave] allComponents에서 설정 찾음:", modalComponent.id);
}
}
// 🆕 아직도 설정을 찾지 못했으면 화면 레이아웃 API에서 가져오기
if (!modalComponentConfig && screenId) {
try {
console.log("🔍 [handleUniversalFormModalTableSectionSave] 화면 레이아웃 API에서 설정 조회:", screenId);
const { screenApi } = await import("@/lib/api/screen");
const layoutData = await screenApi.getLayout(screenId);
if (layoutData && layoutData.components) {
// 레이아웃에서 universal-form-modal 컴포넌트 찾기
const modalLayout = (layoutData.components as any[]).find(
(comp) =>
comp.properties?.columnName === universalFormModalKey || comp.columnName === universalFormModalKey,
);
if (modalLayout) {
modalComponentConfig = modalLayout.properties?.componentConfig || modalLayout.componentConfig;
console.log(
"🎯 [handleUniversalFormModalTableSectionSave] 화면 레이아웃에서 설정 찾음:",
modalLayout.componentId,
);
}
}
} catch (error) {
console.warn("⚠️ [handleUniversalFormModalTableSectionSave] 화면 레이아웃 조회 실패:", error);
}
}
const sections: any[] = modalComponentConfig?.sections || [];
const saveConfig = modalComponentConfig?.saveConfig || {};
console.log("🎯 [handleUniversalFormModalTableSectionSave] 컴포넌트 설정:", {
hasComponentConfig: !!modalComponentConfig,
sectionsCount: sections.length,
mainTableName: saveConfig.tableName || tableName,
sectionSaveModes: saveConfig.sectionSaveModes,
sectionDetails: sections.map((s: any) => ({
id: s.id,
type: s.type,
targetTable: s.tableConfig?.saveConfig?.targetTable,
})),
});
// _tableSection_ 데이터 추출
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
@ -1548,6 +1608,66 @@ export class ButtonActionExecutor {
return { handled: false, success: false };
}
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작");
const fieldsWithNumbering: Record<string, string> = {};
// commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(modalData)) {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumbering[fieldName] = value as string;
console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
}
}
// formData에서도 확인 (모달 외부에 있을 수 있음)
for (const [key, value] of Object.entries(formData)) {
if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumbering[fieldName] = value as string;
console.log(
`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`,
);
}
}
console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(
`🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`,
);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(
`✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]}${newCode}`,
);
commonFieldsData[fieldName] = newCode;
} else {
console.warn(
`⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`,
allocateResult.error,
);
}
} catch (allocateError) {
console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError);
// 오류 시 기존 값 유지
}
}
}
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
try {
// 사용자 정보 추가
if (!context.userId) {
@ -1564,10 +1684,64 @@ export class ButtonActionExecutor {
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
let mainRecordId: number | null = null;
// 🆕 먼저 메인 테이블에 공통 데이터 저장 (별도 테이블이 있는 경우에만)
const hasSeparateTargetTable = sections.some(
(s) =>
s.type === "table" &&
s.tableConfig?.saveConfig?.targetTable &&
s.tableConfig.saveConfig.targetTable !== tableName,
);
if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) {
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블에 공통 데이터 저장:", tableName);
const mainRowToSave = { ...commonFieldsData, ...userInfo };
// 메타데이터 제거
Object.keys(mainRowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete mainRowToSave[key];
}
});
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
const mainSaveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: mainRowToSave,
});
if (!mainSaveResult.success) {
throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
}
mainRecordId = mainSaveResult.data?.id || null;
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
}
// 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`);
console.log(
`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`,
);
// 🆕 해당 섹션의 설정 찾기
const sectionConfig = sections.find((s) => s.id === sectionId);
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
// 🆕 실제 저장할 테이블 결정
// - targetTable이 있으면 해당 테이블에 저장
// - targetTable이 없으면 메인 테이블에 저장
const saveTableName = targetTableName || tableName!;
console.log(`📊 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 테이블:`, {
targetTableName,
saveTableName,
isMainTable: saveTableName === tableName,
});
// 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
@ -1581,11 +1755,16 @@ export class ButtonActionExecutor {
}
});
console.log(" [INSERT] 신규 품목:", rowToSave);
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
console.log(" [INSERT] 신규 품목:", { tableName: saveTableName, data: rowToSave });
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
tableName: saveTableName,
data: rowToSave,
});
@ -1612,9 +1791,14 @@ export class ButtonActionExecutor {
});
delete rowToSave.id; // id 제거하여 INSERT
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
tableName: saveTableName,
data: rowToSave,
});
@ -1631,14 +1815,14 @@ export class ButtonActionExecutor {
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) {
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`);
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}, tableName=${saveTableName}`);
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id,
originalItem,
currentDataWithCommon,
tableName!,
saveTableName,
);
if (!updateResult.success) {
@ -1656,9 +1840,9 @@ export class ButtonActionExecutor {
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`);
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패");
@ -1670,6 +1854,7 @@ export class ButtonActionExecutor {
// 결과 메시지 생성
const resultParts: string[] = [];
if (mainRecordId) resultParts.push("메인 데이터 저장");
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
@ -1679,6 +1864,84 @@ export class ButtonActionExecutor {
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
toast.success(`저장 완료: ${resultMessage}`);
// 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행)
if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) {
const flowId = config.dataflowConfig.flowConfig.flowId;
console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId });
try {
// 플로우 소스 테이블 조회
const { getFlowSourceTable } = await import("@/lib/api/nodeFlows");
const flowSourceInfo = await getFlowSourceTable(flowId);
console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo);
if (flowSourceInfo.sourceTable) {
// 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기
let controlExecuted = false;
for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) {
const sectionConfig = sections.find((s: any) => s.id === sectionId);
const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName;
console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, {
sectionTargetTable,
flowSourceTable: flowSourceInfo.sourceTable,
isMatch: sectionTargetTable === flowSourceInfo.sourceTable,
});
// 소스 테이블과 일치하는 섹션만 제어 실행
if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) {
console.log(
`✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`,
);
// 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성
const sourceData = sectionItems.map((item: any) => ({
...commonFieldsData,
...item,
}));
console.log(
`📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}`,
sourceData[0],
);
// 제어 관리용 컨텍스트 생성
const controlContext: ButtonActionContext = {
...context,
selectedRowsData: sourceData,
formData: commonFieldsData,
};
// 제어 관리 실행
await this.executeAfterSaveControl(config, controlContext);
controlExecuted = true;
break; // 첫 번째 매칭 섹션만 실행
}
}
// 매칭되는 섹션이 없으면 메인 테이블 확인
if (!controlExecuted && tableName === flowSourceInfo.sourceTable) {
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행");
const controlContext: ButtonActionContext = {
...context,
selectedRowsData: [commonFieldsData],
formData: commonFieldsData,
};
await this.executeAfterSaveControl(config, controlContext);
}
} else {
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵");
}
} catch (controlError) {
console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError);
// 제어 관리 실패는 저장 성공에 영향주지 않음
}
}
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
@ -2145,17 +2408,20 @@ export class ButtonActionExecutor {
*
* RelatedDataButtons
*/
private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
private static async handleOpenRelatedModal(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
console.log("🔍 [openRelatedModal] 설정 확인:", {
config,
relatedModalConfig: config.relatedModalConfig,
targetScreenId: config.targetScreenId,
finalTargetScreenId: targetScreenId,
});
if (!targetScreenId) {
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
toast.error("모달 화면 ID가 설정되지 않았습니다.");
@ -2164,13 +2430,13 @@ export class ButtonActionExecutor {
// RelatedDataButtons에서 선택된 데이터 가져오기
const relatedData = window.__relatedButtonsSelectedData;
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
relatedData,
selectedItem: relatedData?.selectedItem,
config: relatedData?.config,
});
if (!relatedData?.selectedItem) {
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
toast.warning("먼저 버튼을 선택해주세요.");
@ -2181,14 +2447,14 @@ export class ButtonActionExecutor {
// 데이터 매핑 적용
const initialData: Record<string, any> = {};
console.log("🔍 [openRelatedModal] 매핑 설정:", {
modalLink: relatedConfig?.modalLink,
dataMapping: relatedConfig?.modalLink?.dataMapping,
});
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
relatedConfig.modalLink.dataMapping.forEach(mapping => {
relatedConfig.modalLink.dataMapping.forEach((mapping) => {
console.log("🔍 [openRelatedModal] 매핑 처리:", {
mapping,
sourceField: mapping.sourceField,
@ -2197,7 +2463,7 @@ export class ButtonActionExecutor {
selectedItemId: selectedItem.id,
rawDataValue: selectedItem.rawData[mapping.sourceField],
});
if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") {
@ -2219,18 +2485,20 @@ export class ButtonActionExecutor {
});
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: {
screenId: targetScreenId,
title: config.modalTitle,
description: config.modalDescription,
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
onSuccess: () => {
// 성공 후 데이터 새로고침
window.dispatchEvent(new CustomEvent("refreshTableData"));
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: targetScreenId,
title: config.modalTitle,
description: config.modalDescription,
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
onSuccess: () => {
// 성공 후 데이터 새로고침
window.dispatchEvent(new CustomEvent("refreshTableData"));
},
},
},
}));
}),
);
return true;
}
@ -3296,10 +3564,7 @@ export class ButtonActionExecutor {
* EditModal public으로
*
*/
public static async executeAfterSaveControl(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<void> {
public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<void> {
console.log("🎯 저장 후 제어 실행:", {
enableDataflowControl: config.enableDataflowControl,
dataflowConfig: config.dataflowConfig,
@ -4742,7 +5007,7 @@ export class ButtonActionExecutor {
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
const isTrackingActive = !!this.trackingIntervalId;
if (!isTrackingActive) {
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
@ -4758,25 +5023,26 @@ export class ButtonActionExecutor {
let dbDeparture: string | null = null;
let dbArrival: string | null = null;
let dbVehicleId: string | null = null;
const userId = context.userId || this.trackingUserId;
if (userId) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles";
const statusTableName =
config.trackingStatusTableName ||
this.trackingConfig?.trackingStatusTableName ||
context.tableName ||
"vehicles";
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
// DB에서 현재 차량 정보 조회
const vehicleResponse = await apiClient.post(
`/table-management/tables/${statusTableName}/data`,
{
page: 1,
size: 1,
search: { [keyField]: userId },
autoFilter: true,
},
);
const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
page: 1,
size: 1,
search: { [keyField]: userId },
autoFilter: true,
});
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
if (vehicleData) {
dbDeparture = vehicleData.departure || null;
@ -4792,14 +5058,18 @@ export class ButtonActionExecutor {
// 마지막 위치 저장 (추적 중이었던 경우에만)
if (isTrackingActive) {
// DB 값 우선, 없으면 formData 사용
const departure = dbDeparture ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
const arrival = dbArrival ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departure =
dbDeparture ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] ||
null;
const arrival =
dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId = dbVehicleId ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
const vehicleId =
dbVehicleId ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] ||
null;
await this.saveLocationToHistory(
tripId,
@ -5681,10 +5951,10 @@ export class ButtonActionExecutor {
const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) {
console.log(`📍 매핑 처리 시작:`, mapping);
console.log("📍 매핑 처리 시작:", mapping);
if (!mapping.targetColumn) {
console.log(`📍 targetColumn 없음, 스킵`);
console.log("📍 targetColumn 없음, 스킵");
continue;
}
@ -5692,12 +5962,12 @@ export class ButtonActionExecutor {
switch (mapping.sourceType) {
case "component":
console.log(`📍 component 타입 처리:`, {
console.log("📍 component 타입 처리:", {
sourceComponentId: mapping.sourceComponentId,
sourceColumnName: mapping.sourceColumnName,
targetColumn: mapping.targetColumn,
});
// 컴포넌트의 현재 값
if (mapping.sourceComponentId) {
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
@ -5705,34 +5975,34 @@ export class ButtonActionExecutor {
value = formData?.[mapping.sourceColumnName];
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
}
// 2. 없으면 컴포넌트 ID로 직접 찾기
if (value === undefined) {
value = formData?.[mapping.sourceComponentId];
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
}
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
if (value === undefined && context.allComponents) {
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
console.log(`📍 방법3 찾은 컴포넌트:`, comp);
console.log("📍 방법3 찾은 컴포넌트:", comp);
if (comp?.columnName) {
value = formData?.[comp.columnName];
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId}${comp.columnName} = ${value}`);
}
}
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
value = formData[mapping.targetColumn];
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
}
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
if (value === undefined) {
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
}
// sourceColumn이 지정된 경우 해당 속성 추출
if (mapping.sourceColumn && value && typeof value === "object") {
value = value[mapping.sourceColumn];
@ -5742,7 +6012,7 @@ export class ButtonActionExecutor {
break;
case "leftPanel":
console.log(`📍 leftPanel 타입 처리:`, {
console.log("📍 leftPanel 타입 처리:", {
sourceColumn: mapping.sourceColumn,
selectedLeftData: splitPanelContext?.selectedLeftData,
});
@ -5775,18 +6045,18 @@ export class ButtonActionExecutor {
}
console.log(`📍 currentUser 값: ${value}`);
break;
default:
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
}
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
} else {
console.log(`📍 값이 비어있어서 insertData에 추가 안됨`);
console.log("📍 값이 비어있어서 insertData에 추가 안됨");
}
}
@ -5794,12 +6064,12 @@ export class ButtonActionExecutor {
if (splitPanelContext?.selectedLeftData) {
const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
// 대상 테이블의 컬럼 목록 조회
let targetTableColumns: string[] = [];
try {
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
@ -5809,35 +6079,35 @@ export class ButtonActionExecutor {
} catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error);
}
for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) {
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
continue;
}
// 대상 테이블에 해당 컬럼이 없으면 스킵
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
continue;
}
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
if (systemColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
continue;
}
// _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) {
if (key.endsWith("_label") || key.endsWith("_name")) {
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
continue;
}
// 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') {
if (val !== undefined && val !== null && val !== "") {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
@ -5857,7 +6127,7 @@ export class ButtonActionExecutor {
enabled: quickInsertConfig.duplicateCheck?.enabled,
columns: quickInsertConfig.duplicateCheck?.columns,
});
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
const duplicateCheckData: Record<string, any> = {};
for (const col of quickInsertConfig.duplicateCheck.columns) {
@ -5877,15 +6147,20 @@ export class ButtonActionExecutor {
page: 1,
pageSize: 1,
search: duplicateCheckData,
}
},
);
console.log("📍 중복 체크 응답:", checkResponse.data);
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0);
console.log(
"📍 기존 데이터:",
existingData,
"길이:",
Array.isArray(existingData) ? existingData.length : 0,
);
if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return false;
@ -5902,20 +6177,20 @@ export class ButtonActionExecutor {
// 데이터 저장
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData
insertData,
);
if (response.data?.success) {
console.log("✅ Quick Insert 저장 성공");
// 저장 후 동작 설정 로그
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
console.log("📍 데이터 새로고침 여부:", shouldRefresh);
if (shouldRefresh) {
console.log("📍 데이터 새로고침 이벤트 발송");
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림