Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal
This commit is contained in:
commit
42d75e1aaf
|
|
@ -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 컨트롤러
|
||||||
|
|
@ -141,6 +141,110 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/switch-company
|
||||||
|
* WACE 관리자 전용: 다른 회사로 전환
|
||||||
|
*/
|
||||||
|
static async switchCompany(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.body;
|
||||||
|
const authHeader = req.get("Authorization");
|
||||||
|
const token = authHeader && authHeader.split(" ")[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 토큰이 필요합니다.",
|
||||||
|
error: { code: "TOKEN_MISSING" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 사용자 정보 확인
|
||||||
|
const currentUser = JwtUtils.verifyToken(token);
|
||||||
|
|
||||||
|
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
|
||||||
|
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
|
||||||
|
if (currentUser.userType !== "SUPER_ADMIN") {
|
||||||
|
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전환할 회사 코드 검증
|
||||||
|
if (!companyCode || companyCode.trim() === "") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "전환할 회사 코드가 필요합니다.",
|
||||||
|
error: { code: "INVALID_INPUT" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`=== WACE 관리자 회사 전환 ===`, {
|
||||||
|
userId: currentUser.userId,
|
||||||
|
originalCompanyCode: currentUser.companyCode,
|
||||||
|
targetCompanyCode: companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const { query } = await import("../database/db");
|
||||||
|
const companies = await query<any>(
|
||||||
|
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (companies.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "존재하지 않는 회사 코드입니다.",
|
||||||
|
error: { code: "COMPANY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||||
|
const newPersonBean: PersonBean = {
|
||||||
|
...currentUser,
|
||||||
|
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||||
|
};
|
||||||
|
|
||||||
|
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||||
|
|
||||||
|
logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "회사 전환 완료",
|
||||||
|
data: {
|
||||||
|
token: newToken,
|
||||||
|
companyCode: companyCode.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 전환 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SERVER_ERROR",
|
||||||
|
details:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "알 수 없는 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||||
|
|
@ -226,13 +330,14 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||||
|
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||||
const userInfoResponse: any = {
|
const userInfoResponse: any = {
|
||||||
userId: dbUserInfo.userId,
|
userId: dbUserInfo.userId,
|
||||||
userName: dbUserInfo.userName || "",
|
userName: dbUserInfo.userName || "",
|
||||||
deptName: dbUserInfo.deptName || "",
|
deptName: dbUserInfo.deptName || "",
|
||||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
userType: dbUserInfo.userType || "USER",
|
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||||
email: dbUserInfo.email || "",
|
email: dbUserInfo.email || "",
|
||||||
photo: dbUserInfo.photo,
|
photo: dbUserInfo.photo,
|
||||||
|
|
|
||||||
|
|
@ -1978,15 +1978,21 @@ export async function multiTableSave(
|
||||||
for (const subTableConfig of subTables || []) {
|
for (const subTableConfig of subTables || []) {
|
||||||
const { tableName, linkColumn, items, options } = subTableConfig;
|
const { tableName, linkColumn, items, options } = subTableConfig;
|
||||||
|
|
||||||
if (!tableName || !items || items.length === 0) {
|
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
|
||||||
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
|
||||||
|
options?.mainFieldMappings &&
|
||||||
|
options.mainFieldMappings.length > 0;
|
||||||
|
|
||||||
|
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
|
||||||
|
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||||
itemsCount: items.length,
|
itemsCount: items?.length || 0,
|
||||||
linkColumn,
|
linkColumn,
|
||||||
options,
|
options,
|
||||||
|
hasSaveMainAsFirst,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기존 데이터 삭제 옵션
|
// 기존 데이터 삭제 옵션
|
||||||
|
|
@ -2004,7 +2010,15 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||||
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
|
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
|
||||||
|
logger.info(`saveMainAsFirst 옵션 확인:`, {
|
||||||
|
saveMainAsFirst: options?.saveMainAsFirst,
|
||||||
|
mainFieldMappings: options?.mainFieldMappings,
|
||||||
|
mainFieldMappingsLength: options?.mainFieldMappings?.length,
|
||||||
|
linkColumn,
|
||||||
|
mainDataKeys: Object.keys(mainData),
|
||||||
|
});
|
||||||
|
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
|
||||||
const mainSubItem: Record<string, any> = {
|
const mainSubItem: Record<string, any> = {
|
||||||
[linkColumn.subColumn]: savedPkValue,
|
[linkColumn.subColumn]: savedPkValue,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken);
|
||||||
*/
|
*/
|
||||||
router.post("/signup", AuthController.signup);
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/switch-company
|
||||||
|
* WACE 관리자 전용: 다른 회사로 전환
|
||||||
|
*/
|
||||||
|
router.post("/switch-company", AuthController.switchCompany);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 소스 테이블 조회
|
||||||
|
* GET /api/dataflow/node-flows/:flowId/source-table
|
||||||
|
* 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
|
||||||
|
*/
|
||||||
|
router.get("/:flowId/source-table", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
|
||||||
|
const flow = await queryOne<{ flow_data: any }>(
|
||||||
|
`SELECT flow_data FROM node_flows WHERE flow_id = $1`,
|
||||||
|
[flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowData =
|
||||||
|
typeof flow.flow_data === "string"
|
||||||
|
? JSON.parse(flow.flow_data)
|
||||||
|
: flow.flow_data;
|
||||||
|
|
||||||
|
const nodes = flowData.nodes || [];
|
||||||
|
|
||||||
|
// 소스 노드 찾기 (tableSource, externalDBSource 타입)
|
||||||
|
const sourceNode = nodes.find(
|
||||||
|
(node: any) =>
|
||||||
|
node.type === "tableSource" || node.type === "externalDBSource"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourceNode || !sourceNode.data?.tableName) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceTable: null,
|
||||||
|
sourceNodeType: null,
|
||||||
|
message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceTable: sourceNode.data.tableName,
|
||||||
|
sourceNodeType: sourceNode.type,
|
||||||
|
sourceNodeId: sourceNode.id,
|
||||||
|
displayName: sourceNode.data.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 소스 테이블 조회 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우 소스 테이블을 조회하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
|
|
|
||||||
|
|
@ -412,9 +412,9 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
if (userType === "SUPER_ADMIN") {
|
||||||
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
|
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
||||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
||||||
authFilter = "";
|
authFilter = "";
|
||||||
unionFilter = "";
|
unionFilter = "";
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -2201,15 +2201,20 @@ export class MenuCopyService {
|
||||||
"system",
|
"system",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await client.query(
|
const result = await client.query(
|
||||||
`INSERT INTO screen_menu_assignments (
|
`INSERT INTO screen_menu_assignments (
|
||||||
screen_id, menu_objid, company_code, display_order, is_active, created_by
|
screen_id, menu_objid, company_code, display_order, is_active, created_by
|
||||||
) VALUES ${assignmentValues}`,
|
) VALUES ${assignmentValues}
|
||||||
|
ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`,
|
||||||
assignmentParams
|
assignmentParams
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`);
|
logger.info(
|
||||||
|
`✅ 화면-메뉴 할당 완료: ${result.rowCount}개 삽입 (${validAssignments.length - (result.rowCount || 0)}개 중복 무시)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(`📭 화면-메뉴 할당할 항목 없음`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export default function DraftsPage() {
|
||||||
content: draft.htmlContent,
|
content: draft.htmlContent,
|
||||||
accountId: draft.accountId,
|
accountId: draft.accountId,
|
||||||
});
|
});
|
||||||
router.push(`/admin/mail/send?${params.toString()}`);
|
router.push(`/admin/automaticMng/mail/send?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
|
@ -1056,7 +1056,7 @@ ${data.originalBody}`;
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.push(`/admin/mail/templates`)}
|
onClick={() => router.push(`/admin/automaticMng/mail/templates`)}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Settings className="w-3 h-3" />
|
<Settings className="w-3 h-3" />
|
||||||
|
|
@ -336,7 +336,7 @@ export default function SentMailPage() {
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => router.push("/admin/mail/send")} size="sm">
|
<Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
메일 작성
|
메일 작성
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
|
|
||||||
|
|
||||||
export default function DepartmentManagementPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const companyCode = params.companyCode as string;
|
|
||||||
|
|
||||||
return <DepartmentManagement companyCode={companyCode} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { CompanyManagement } from "@/components/admin/CompanyManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회사 관리 페이지
|
|
||||||
*/
|
|
||||||
export default function CompanyPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">회사 관리</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<CompanyManagement />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,449 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
|
||||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 목록 클라이언트 컴포넌트
|
|
||||||
* - CSR 방식으로 초기 데이터 로드
|
|
||||||
* - 대시보드 목록 조회
|
|
||||||
* - 대시보드 생성/수정/삭제/복사
|
|
||||||
*/
|
|
||||||
export default function DashboardListClient() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// 페이지네이션 상태
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(10);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
|
||||||
|
|
||||||
// 대시보드 목록 로드
|
|
||||||
const loadDashboards = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await dashboardApi.getMyDashboards({
|
|
||||||
search: searchTerm,
|
|
||||||
page: currentPage,
|
|
||||||
limit: pageSize,
|
|
||||||
});
|
|
||||||
setDashboards(result.dashboards);
|
|
||||||
setTotalCount(result.pagination.total);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load dashboards:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
|
||||||
useEffect(() => {
|
|
||||||
loadDashboards();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchTerm, currentPage, pageSize]);
|
|
||||||
|
|
||||||
// 페이지네이션 정보 계산
|
|
||||||
const paginationInfo: PaginationInfo = {
|
|
||||||
currentPage,
|
|
||||||
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
|
||||||
totalItems: totalCount,
|
|
||||||
itemsPerPage: pageSize,
|
|
||||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
|
||||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 변경 핸들러
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 크기 변경 핸들러
|
|
||||||
const handlePageSizeChange = (size: number) => {
|
|
||||||
setPageSize(size);
|
|
||||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 삭제 확인 모달 열기
|
|
||||||
const handleDeleteClick = (id: string, title: string) => {
|
|
||||||
setDeleteTarget({ id, title });
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 삭제 실행
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
toast({
|
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete dashboard:", err);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 삭제에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 복사
|
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
|
||||||
try {
|
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
|
||||||
|
|
||||||
await dashboardApi.createDashboard({
|
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
|
||||||
description: fullDashboard.description,
|
|
||||||
elements: fullDashboard.elements || [],
|
|
||||||
isPublic: false,
|
|
||||||
tags: fullDashboard.tags,
|
|
||||||
category: fullDashboard.category,
|
|
||||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 복사되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy dashboard:", err);
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 복사에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 포맷팅 헬퍼
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 검색 및 액션 */}
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative w-full sm:w-[300px]">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
placeholder="대시보드 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="h-10 pl-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
|
||||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
|
||||||
<TableRow key={index} className="border-b">
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex justify-between">
|
|
||||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : error ? (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
|
||||||
<AlertCircle className="text-destructive h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
|
||||||
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : dashboards.length === 0 ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
|
||||||
<TableCell className="h-16 text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
{dashboard.title}
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
|
||||||
{dashboard.description || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.createdAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.updatedAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
삭제
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<div
|
|
||||||
key={dashboard.id}
|
|
||||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
|
||||||
</button>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">설명</span>
|
|
||||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성자</span>
|
|
||||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">수정일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 */}
|
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => handleCopy(dashboard)}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
{!loading && dashboards.length > 0 && (
|
|
||||||
<Pagination
|
|
||||||
paginationInfo={paginationInfo}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
onPageSizeChange={handlePageSizeChange}
|
|
||||||
showPageSizeSelector={true}
|
|
||||||
pageSizeOptions={[10, 20, 50, 100]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
|
||||||
<DeleteConfirmModal
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
title="대시보드 삭제"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { use } from "react";
|
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 편집 페이지
|
|
||||||
* - 기존 대시보드 편집
|
|
||||||
*/
|
|
||||||
export default function DashboardEditPage({ params }: PageProps) {
|
|
||||||
const { id } = use(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner dashboardId={id} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새 대시보드 생성 페이지
|
|
||||||
*/
|
|
||||||
export default function DashboardNewPage() {
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 관리 페이지
|
|
||||||
* - 클라이언트 컴포넌트를 렌더링하는 래퍼
|
|
||||||
* - 초기 로딩부터 CSR로 처리
|
|
||||||
*/
|
|
||||||
export default function DashboardListPage() {
|
|
||||||
return (
|
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 클라이언트 컴포넌트 */}
|
|
||||||
<DashboardListClient />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import MultiLang from "@/components/admin/MultiLang";
|
|
||||||
|
|
||||||
export default function I18nPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<div className="w-full max-w-none px-4 py-8">
|
|
||||||
<MultiLang />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,9 +1,124 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { BatchAPI, BatchMonitoring } from "@/lib/api/batch";
|
||||||
|
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
|
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMonitoringData();
|
||||||
|
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
if (autoRefresh) {
|
||||||
|
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
const loadMonitoringData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await BatchAPI.getBatchMonitoring();
|
||||||
|
setMonitoring(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("모니터링 데이터 조회 오류:", error);
|
||||||
|
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadMonitoringData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoRefresh = () => {
|
||||||
|
setAutoRefresh(!autoRefresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||||
|
case 'running':
|
||||||
|
return <Play className="h-4 w-4 text-blue-500" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const variants = {
|
||||||
|
completed: "bg-green-100 text-green-800",
|
||||||
|
failed: "bg-destructive/20 text-red-800",
|
||||||
|
running: "bg-primary/20 text-blue-800",
|
||||||
|
pending: "bg-yellow-100 text-yellow-800",
|
||||||
|
cancelled: "bg-gray-100 text-gray-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
completed: "완료",
|
||||||
|
failed: "실패",
|
||||||
|
running: "실행 중",
|
||||||
|
pending: "대기 중",
|
||||||
|
cancelled: "취소됨",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
||||||
|
{labels[status as keyof typeof labels] || status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number) => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${(ms / 60000).toFixed(1)}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSuccessRate = () => {
|
||||||
|
if (!monitoring) return 0;
|
||||||
|
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
||||||
|
if (total === 0) return 100;
|
||||||
|
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!monitoring) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||||
|
<p>모니터링 데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
|
@ -16,7 +131,170 @@ export default function MonitoringPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모니터링 대시보드 */}
|
{/* 모니터링 대시보드 */}
|
||||||
<MonitoringDashboard />
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleAutoRefresh}
|
||||||
|
className={autoRefresh ? "bg-accent text-primary" : ""}
|
||||||
|
>
|
||||||
|
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||||
|
자동 새로고침
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
||||||
|
<div className="text-2xl">📋</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
활성: {monitoring.active_jobs}개
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
||||||
|
<div className="text-2xl">🔄</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
현재 실행 중인 작업
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
||||||
|
<div className="text-2xl">✅</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
성공률: {getSuccessRate()}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
||||||
|
<div className="text-2xl">❌</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
주의가 필요한 작업
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 성공률 진행바 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>성공: {monitoring.successful_jobs_today}건</span>
|
||||||
|
<span>실패: {monitoring.failed_jobs_today}건</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={getSuccessRate()} className="h-2" />
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
{getSuccessRate()}% 성공률
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 최근 실행 이력 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{monitoring.recent_executions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
최근 실행 이력이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead>작업 ID</TableHead>
|
||||||
|
<TableHead>시작 시간</TableHead>
|
||||||
|
<TableHead>완료 시간</TableHead>
|
||||||
|
<TableHead>실행 시간</TableHead>
|
||||||
|
<TableHead>오류 메시지</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{monitoring.recent_executions.map((execution) => (
|
||||||
|
<TableRow key={execution.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(execution.execution_status)}
|
||||||
|
{getStatusBadge(execution.execution_status)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.started_at
|
||||||
|
? new Date(execution.started_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.completed_at
|
||||||
|
? new Date(execution.completed_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.execution_time_ms
|
||||||
|
? formatDuration(execution.execution_time_ms)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs">
|
||||||
|
{execution.error_message ? (
|
||||||
|
<span className="text-destructive text-sm truncate block">
|
||||||
|
{execution.error_message}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||||
|
|
||||||
{/* 주요 관리 기능 */}
|
{/* 주요 관리 기능 */}
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
|
|
@ -168,7 +169,7 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/external-connections" className="block">
|
<Link href="/admin/automaticMng/exconList" className="block">
|
||||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
|
@ -182,7 +183,7 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/commonCode" className="block">
|
<Link href="/admin/systemMng/commonCodeList" className="block">
|
||||||
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { use } from "react";
|
|
||||||
import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 상세 페이지
|
|
||||||
* URL: /admin/roles/[id]
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 권한 그룹 멤버 관리 (Dual List Box)
|
|
||||||
* - 메뉴 권한 설정 (CRUD 체크박스)
|
|
||||||
*/
|
|
||||||
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
||||||
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
|
|
||||||
const { id } = use(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<RoleDetailManagement roleId={id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { RoleManagement } from "@/components/admin/RoleManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 관리 페이지
|
|
||||||
* URL: /admin/roles
|
|
||||||
*
|
|
||||||
* shadcn/ui 스타일 가이드 적용
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 회사별 권한 그룹 목록 조회
|
|
||||||
* - 권한 그룹 생성/수정/삭제
|
|
||||||
* - 멤버 관리 (Dual List Box)
|
|
||||||
* - 메뉴 권한 설정 (CRUD 권한)
|
|
||||||
*/
|
|
||||||
export default function RolesPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<RoleManagement />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import { use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardCanvas } from "./DashboardCanvas";
|
import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
|
||||||
import { DashboardTopMenu } from "./DashboardTopMenu";
|
import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
|
||||||
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
|
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
|
||||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
|
||||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
|
||||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
|
||||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -32,18 +33,24 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
interface DashboardDesignerProps {
|
|
||||||
dashboardId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 설계 도구 메인 컴포넌트
|
* 대시보드 생성/편집 페이지
|
||||||
|
* URL: /admin/screenMng/dashboardList/[id]
|
||||||
|
* - id가 "new"면 새 대시보드 생성
|
||||||
|
* - id가 숫자면 기존 대시보드 편집
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
* - 드래그 앤 드롭으로 차트/위젯 배치
|
||||||
* - 그리드 기반 레이아웃 (12 컬럼)
|
* - 그리드 기반 레이아웃 (12 컬럼)
|
||||||
* - 요소 이동, 크기 조절, 삭제 기능
|
* - 요소 이동, 크기 조절, 삭제 기능
|
||||||
* - 레이아웃 저장/불러오기 기능
|
* - 레이아웃 저장/불러오기 기능
|
||||||
*/
|
*/
|
||||||
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
|
export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: paramId } = use(params);
|
||||||
|
|
||||||
|
// "new"면 생성 모드, 아니면 편집 모드
|
||||||
|
const initialDashboardId = paramId === "new" ? undefined : paramId;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||||
|
|
@ -643,7 +650,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
open={successModalOpen}
|
open={successModalOpen}
|
||||||
onOpenChange={() => {
|
onOpenChange={() => {
|
||||||
setSuccessModalOpen(false);
|
setSuccessModalOpen(false);
|
||||||
router.push("/admin/dashboard");
|
router.push("/admin/screenMng/dashboardList");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
|
|
@ -660,7 +667,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSuccessModalOpen(false);
|
setSuccessModalOpen(false);
|
||||||
router.push("/admin/dashboard");
|
router.push("/admin/screenMng/dashboardList");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
|
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||||
|
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 관리 페이지
|
||||||
|
* - CSR 방식으로 초기 데이터 로드
|
||||||
|
* - 대시보드 목록 조회
|
||||||
|
* - 대시보드 생성/수정/삭제/복사
|
||||||
|
*/
|
||||||
|
export default function DashboardListPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 페이지네이션 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
// 대시보드 목록 로드
|
||||||
|
const loadDashboards = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await dashboardApi.getMyDashboards({
|
||||||
|
search: searchTerm,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
setDashboards(result.dashboards);
|
||||||
|
setTotalCount(result.pagination.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load dashboards:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboards();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchTerm, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 페이지네이션 정보 계산
|
||||||
|
const paginationInfo: PaginationInfo = {
|
||||||
|
currentPage,
|
||||||
|
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
||||||
|
totalItems: totalCount,
|
||||||
|
itemsPerPage: pageSize,
|
||||||
|
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||||
|
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경 핸들러
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 크기 변경 핸들러
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 삭제 확인 모달 열기
|
||||||
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
|
setDeleteTarget({ id, title });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 삭제 실행
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
loadDashboards();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete dashboard:", err);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 삭제에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 복사
|
||||||
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
|
try {
|
||||||
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
|
await dashboardApi.createDashboard({
|
||||||
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
|
description: fullDashboard.description,
|
||||||
|
elements: fullDashboard.elements || [],
|
||||||
|
isPublic: false,
|
||||||
|
tags: fullDashboard.tags,
|
||||||
|
category: fullDashboard.category,
|
||||||
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 복사되었습니다.",
|
||||||
|
});
|
||||||
|
loadDashboards();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy dashboard:", err);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 복사에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 포맷팅 헬퍼
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 액션 */}
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="대시보드 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
새 대시보드 생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대시보드 목록 */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<TableRow key={index} className="border-b">
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<AlertCircle className="text-destructive h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
||||||
|
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : dashboards.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
|
<TableCell className="h-16 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{dashboard.title}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
|
{dashboard.description || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<div
|
||||||
|
key={dashboard.id}
|
||||||
|
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||||
|
</button>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">설명</span>
|
||||||
|
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성자</span>
|
||||||
|
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">수정일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 */}
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => handleCopy(dashboard)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && dashboards.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
title="대시보드 삭제"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ export default function ReportDesignerPage() {
|
||||||
description: "리포트를 찾을 수 없습니다.",
|
description: "리포트를 찾을 수 없습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
router.push("/admin/report");
|
router.push("/admin/screenMng/reportList");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -45,7 +45,7 @@ export default function ReportDesignerPage() {
|
||||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
router.push("/admin/report");
|
router.push("/admin/screenMng/reportList");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ export default function ReportManagementPage() {
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
|
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
|
||||||
router.push("/admin/report/designer/new");
|
router.push("/admin/screenMng/reportList/designer/new");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -13,7 +13,7 @@ export default function NodeEditorPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// /admin/dataflow 메인 페이지로 리다이렉트
|
// /admin/dataflow 메인 페이지로 리다이렉트
|
||||||
router.replace("/admin/dataflow");
|
router.replace("/admin/systemMng/dataflow");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,17 +51,17 @@ export default function DataFlowPage() {
|
||||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||||
if (isEditorMode) {
|
if (isEditorMode) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="bg-background fixed inset-0 z-50">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 에디터 헤더 */}
|
{/* 에디터 헤더 */}
|
||||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
<div className="bg-background flex items-center gap-4 border-b p-4">
|
||||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,12 +77,12 @@ export default function DataFlowPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
<p className="text-muted-foreground text-sm">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 목록 */}
|
{/* 플로우 목록 */}
|
||||||
|
|
@ -11,8 +11,8 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { DataTable } from "@/components/common/DataTable";
|
import { DataTable } from "@/components/common/DataTable";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import LangKeyModal from "./LangKeyModal";
|
import LangKeyModal from "@/components/admin/LangKeyModal";
|
||||||
import LanguageModal from "./LanguageModal";
|
import LanguageModal from "@/components/admin/LanguageModal";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
interface Language {
|
interface Language {
|
||||||
|
|
@ -39,7 +39,7 @@ interface LangText {
|
||||||
isActive: string;
|
isActive: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultiLangPage() {
|
export default function I18nPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [languages, setLanguages] = useState<Language[]>([]);
|
const [languages, setLanguages] = useState<Language[]>([]);
|
||||||
|
|
@ -64,20 +64,14 @@ export default function MultiLangPage() {
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
const fetchCompanies = async () => {
|
const fetchCompanies = async () => {
|
||||||
try {
|
try {
|
||||||
// console.log("회사 목록 조회 시작");
|
|
||||||
const response = await apiClient.get("/admin/companies");
|
const response = await apiClient.get("/admin/companies");
|
||||||
// console.log("회사 목록 응답 데이터:", response.data);
|
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const companyList = data.data.map((company: any) => ({
|
const companyList = data.data.map((company: any) => ({
|
||||||
code: company.company_code,
|
code: company.company_code,
|
||||||
name: company.company_name,
|
name: company.company_name,
|
||||||
}));
|
}));
|
||||||
// console.log("변환된 회사 목록:", companyList);
|
|
||||||
setCompanies(companyList);
|
setCompanies(companyList);
|
||||||
} else {
|
|
||||||
// console.error("회사 목록 조회 실패:", data.message);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("회사 목록 조회 실패:", error);
|
// console.error("회사 목록 조회 실패:", error);
|
||||||
|
|
@ -103,17 +97,14 @@ export default function MultiLangPage() {
|
||||||
const response = await apiClient.get("/multilang/keys");
|
const response = await apiClient.get("/multilang/keys");
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
|
|
||||||
setLangKeys(data.data);
|
setLangKeys(data.data);
|
||||||
} else {
|
|
||||||
// console.error("❌ 키 목록 로드 실패:", data.message);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("다국어 키 목록 조회 실패:", error);
|
// console.error("다국어 키 목록 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터링된 데이터 계산 - 메뉴관리와 동일한 방식
|
// 필터링된 데이터 계산
|
||||||
const getFilteredLangKeys = () => {
|
const getFilteredLangKeys = () => {
|
||||||
let filteredKeys = langKeys;
|
let filteredKeys = langKeys;
|
||||||
|
|
||||||
|
|
@ -146,16 +137,12 @@ export default function MultiLangPage() {
|
||||||
// 선택된 키의 다국어 텍스트 조회
|
// 선택된 키의 다국어 텍스트 조회
|
||||||
const fetchLangTexts = async (keyId: number) => {
|
const fetchLangTexts = async (keyId: number) => {
|
||||||
try {
|
try {
|
||||||
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
|
|
||||||
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
// console.log("다국어 텍스트 조회 응답:", data);
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setLangTexts(data.data);
|
setLangTexts(data.data);
|
||||||
// 편집용 텍스트 초기화
|
|
||||||
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
||||||
setEditingTexts(editingData);
|
setEditingTexts(editingData);
|
||||||
// console.log("편집용 텍스트 설정:", editingData);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("다국어 텍스트 조회 실패:", error);
|
// console.error("다국어 텍스트 조회 실패:", error);
|
||||||
|
|
@ -164,20 +151,10 @@ export default function MultiLangPage() {
|
||||||
|
|
||||||
// 언어 키 선택 처리
|
// 언어 키 선택 처리
|
||||||
const handleKeySelect = (key: LangKey) => {
|
const handleKeySelect = (key: LangKey) => {
|
||||||
// console.log("언어 키 선택:", key);
|
|
||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
fetchLangTexts(key.keyId);
|
fetchLangTexts(key.keyId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디버깅용 useEffect
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedKey) {
|
|
||||||
// console.log("선택된 키 변경:", selectedKey);
|
|
||||||
// console.log("언어 목록:", languages);
|
|
||||||
// console.log("편집 텍스트:", editingTexts);
|
|
||||||
}
|
|
||||||
}, [selectedKey, languages, editingTexts]);
|
|
||||||
|
|
||||||
// 텍스트 변경 처리
|
// 텍스트 변경 처리
|
||||||
const handleTextChange = (langCode: string, value: string) => {
|
const handleTextChange = (langCode: string, value: string) => {
|
||||||
const newEditingTexts = [...editingTexts];
|
const newEditingTexts = [...editingTexts];
|
||||||
|
|
@ -203,7 +180,6 @@ export default function MultiLangPage() {
|
||||||
if (!selectedKey) return;
|
if (!selectedKey) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 백엔드가 기대하는 형식으로 데이터 변환
|
|
||||||
const requestData = {
|
const requestData = {
|
||||||
texts: editingTexts.map((text) => ({
|
texts: editingTexts.map((text) => ({
|
||||||
langCode: text.langCode,
|
langCode: text.langCode,
|
||||||
|
|
@ -218,18 +194,16 @@ export default function MultiLangPage() {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert("저장되었습니다.");
|
alert("저장되었습니다.");
|
||||||
// 저장 후 다시 조회
|
|
||||||
fetchLangTexts(selectedKey.keyId);
|
fetchLangTexts(selectedKey.keyId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("텍스트 저장 실패:", error);
|
|
||||||
alert("저장에 실패했습니다.");
|
alert("저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 언어 키 추가/수정 모달 열기
|
// 언어 키 추가/수정 모달 열기
|
||||||
const handleAddKey = () => {
|
const handleAddKey = () => {
|
||||||
setEditingKey(null); // 새 키 추가는 null로 설정
|
setEditingKey(null);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -266,12 +240,11 @@ export default function MultiLangPage() {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
|
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
|
||||||
setIsLanguageModalOpen(false);
|
setIsLanguageModalOpen(false);
|
||||||
fetchLanguages(); // 언어 목록 새로고침
|
fetchLanguages();
|
||||||
} else {
|
} else {
|
||||||
alert(`오류: ${result.message}`);
|
alert(`오류: ${result.message}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 저장 중 오류:", error);
|
|
||||||
alert("언어 저장 중 오류가 발생했습니다.");
|
alert("언어 저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -302,12 +275,11 @@ export default function MultiLangPage() {
|
||||||
if (failedDeletes.length === 0) {
|
if (failedDeletes.length === 0) {
|
||||||
alert("선택된 언어가 삭제되었습니다.");
|
alert("선택된 언어가 삭제되었습니다.");
|
||||||
setSelectedLanguages(new Set());
|
setSelectedLanguages(new Set());
|
||||||
fetchLanguages(); // 언어 목록 새로고침
|
fetchLanguages();
|
||||||
} else {
|
} else {
|
||||||
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 삭제 중 오류:", error);
|
|
||||||
alert("언어 삭제 중 오류가 발생했습니다.");
|
alert("언어 삭제 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -358,10 +330,9 @@ export default function MultiLangPage() {
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
|
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
|
||||||
fetchLangKeys(); // 목록 새로고침
|
fetchLangKeys();
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} else {
|
} else {
|
||||||
// 중복 체크 오류 메시지 처리
|
|
||||||
if (data.message && data.message.includes("이미 존재하는 언어키")) {
|
if (data.message && data.message.includes("이미 존재하는 언어키")) {
|
||||||
alert(data.message);
|
alert(data.message);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -369,7 +340,6 @@ export default function MultiLangPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 키 저장 실패:", error);
|
|
||||||
alert("언어 키 저장에 실패했습니다.");
|
alert("언어 키 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -397,7 +367,6 @@ export default function MultiLangPage() {
|
||||||
alert("상태 변경 중 오류가 발생했습니다.");
|
alert("상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("키 상태 토글 실패:", error);
|
|
||||||
alert("키 상태 변경 중 오류가 발생했습니다.");
|
alert("키 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -414,7 +383,6 @@ export default function MultiLangPage() {
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 상태 토글 실패:", error);
|
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -453,9 +421,8 @@ export default function MultiLangPage() {
|
||||||
if (allSuccess) {
|
if (allSuccess) {
|
||||||
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
|
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
fetchLangKeys(); // 목록 새로고침
|
fetchLangKeys();
|
||||||
|
|
||||||
// 선택된 키가 삭제된 경우 편집 영역 닫기
|
|
||||||
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
|
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
|
||||||
handleCancel();
|
handleCancel();
|
||||||
}
|
}
|
||||||
|
|
@ -463,12 +430,11 @@ export default function MultiLangPage() {
|
||||||
alert("일부 키 삭제에 실패했습니다.");
|
alert("일부 키 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("선택된 키 삭제 실패:", error);
|
|
||||||
alert("선택된 키 삭제에 실패했습니다.");
|
alert("선택된 키 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 개별 키 삭제 (기존 함수 유지)
|
// 개별 키 삭제
|
||||||
const handleDeleteKey = async (keyId: number) => {
|
const handleDeleteKey = async (keyId: number) => {
|
||||||
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
|
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -479,13 +445,12 @@ export default function MultiLangPage() {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert("언어 키가 영구적으로 삭제되었습니다.");
|
alert("언어 키가 영구적으로 삭제되었습니다.");
|
||||||
fetchLangKeys(); // 목록 새로고침
|
fetchLangKeys();
|
||||||
if (selectedKey && selectedKey.keyId === keyId) {
|
if (selectedKey && selectedKey.keyId === keyId) {
|
||||||
handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기
|
handleCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("언어 키 삭제 실패:", error);
|
|
||||||
alert("언어 키 삭제에 실패했습니다.");
|
alert("언어 키 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -506,8 +471,6 @@ export default function MultiLangPage() {
|
||||||
initializeData();
|
initializeData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 검색 관련 useEffect 제거 - 실시간 필터링만 사용
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
|
|
@ -552,7 +515,6 @@ export default function MultiLangPage() {
|
||||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
|
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
accessorKey: "langKey",
|
accessorKey: "langKey",
|
||||||
header: "언어 키",
|
header: "언어 키",
|
||||||
|
|
@ -567,7 +529,6 @@ export default function MultiLangPage() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "설명",
|
header: "설명",
|
||||||
|
|
@ -667,193 +628,198 @@ export default function MultiLangPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-2">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* 탭 네비게이션 */}
|
<div className="w-full max-w-none px-4 py-8">
|
||||||
<div className="flex space-x-1 border-b">
|
<div className="container mx-auto p-2">
|
||||||
<button
|
{/* 탭 네비게이션 */}
|
||||||
onClick={() => setActiveTab("keys")}
|
<div className="flex space-x-1 border-b">
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
<button
|
||||||
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
onClick={() => setActiveTab("keys")}
|
||||||
}`}
|
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
>
|
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
다국어 키 관리
|
}`}
|
||||||
</button>
|
>
|
||||||
<button
|
다국어 키 관리
|
||||||
onClick={() => setActiveTab("languages")}
|
</button>
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
<button
|
||||||
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
onClick={() => setActiveTab("languages")}
|
||||||
}`}
|
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
>
|
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
언어 관리
|
}`}
|
||||||
</button>
|
>
|
||||||
</div>
|
언어 관리
|
||||||
|
</button>
|
||||||
{/* 메인 콘텐츠 영역 */}
|
|
||||||
<div className="mt-2">
|
|
||||||
{/* 언어 관리 탭 */}
|
|
||||||
{activeTab === "languages" && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>언어 관리</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{selectedLanguages.size > 0 && (
|
|
||||||
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
|
||||||
선택 삭제 ({selectedLanguages.size})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={handleAddLanguage}>새 언어 추가</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DataTable data={languages} columns={languageColumns} searchable />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 다국어 키 관리 탭의 메인 영역 */}
|
|
||||||
{activeTab === "keys" && (
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
|
||||||
{/* 좌측: 언어 키 목록 (7/10) */}
|
|
||||||
<Card className="lg:col-span-7">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>언어 키 목록</CardTitle>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
|
|
||||||
선택 삭제 ({selectedKeys.size})
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleAddKey}>새 키 추가</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* 검색 필터 영역 */}
|
|
||||||
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="company">회사</Label>
|
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="전체 회사" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체 회사</SelectItem>
|
|
||||||
{companies.map((company) => (
|
|
||||||
<SelectItem key={company.code} value={company.code}>
|
|
||||||
{company.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="search">검색</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="키명, 설명, 메뉴, 회사로 검색..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<div className="text-sm text-muted-foreground">검색 결과: {getFilteredLangKeys().length}건</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 영역 */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 text-sm text-muted-foreground">전체: {getFilteredLangKeys().length}건</div>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={getFilteredLangKeys()}
|
|
||||||
searchable={false}
|
|
||||||
onRowClick={handleKeySelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
|
|
||||||
<Card className="lg:col-span-3">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
{selectedKey ? (
|
|
||||||
<>
|
|
||||||
선택된 키:{" "}
|
|
||||||
<Badge variant="secondary" className="ml-2">
|
|
||||||
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"다국어 편집"
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{selectedKey ? (
|
|
||||||
<div>
|
|
||||||
{/* 스크롤 가능한 텍스트 영역 */}
|
|
||||||
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
|
|
||||||
{languages
|
|
||||||
.filter((lang) => lang.isActive === "Y")
|
|
||||||
.map((lang) => {
|
|
||||||
const text = editingTexts.find((t) => t.langCode === lang.langCode);
|
|
||||||
return (
|
|
||||||
<div key={lang.langCode} className="flex items-center space-x-4">
|
|
||||||
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
|
|
||||||
{lang.langName}
|
|
||||||
</Badge>
|
|
||||||
<Input
|
|
||||||
placeholder={`${lang.langName} 텍스트 입력`}
|
|
||||||
value={text?.langText || ""}
|
|
||||||
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 저장 버튼 - 고정 위치 */}
|
|
||||||
<div className="mt-4 flex space-x-2 border-t pt-4">
|
|
||||||
<Button onClick={handleSave}>저장</Button>
|
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
|
||||||
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* 메인 콘텐츠 영역 */}
|
||||||
|
<div className="mt-2">
|
||||||
|
{/* 언어 관리 탭 */}
|
||||||
|
{activeTab === "languages" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>언어 관리</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{selectedLanguages.size > 0 && (
|
||||||
|
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
||||||
|
선택 삭제 ({selectedLanguages.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleAddLanguage}>새 언어 추가</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable data={languages} columns={languageColumns} searchable />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 다국어 키 관리 탭 */}
|
||||||
|
{activeTab === "keys" && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-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>
|
||||||
|
|
||||||
{/* 언어 키 추가/수정 모달 */}
|
|
||||||
<LangKeyModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onSave={handleSaveKey}
|
|
||||||
keyData={editingKey}
|
|
||||||
companies={companies}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 언어 추가/수정 모달 */}
|
|
||||||
<LanguageModal
|
|
||||||
isOpen={isLanguageModalOpen}
|
|
||||||
onClose={() => setIsLanguageModalOpen(false)}
|
|
||||||
onSave={handleSaveLanguage}
|
|
||||||
languageData={editingLanguage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 권한 관리 페이지
|
|
||||||
* URL: /admin/userAuth
|
|
||||||
*
|
|
||||||
* 최고 관리자만 접근 가능
|
|
||||||
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
|
|
||||||
*/
|
|
||||||
export default function UserAuthPage() {
|
|
||||||
return (
|
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">사용자 권한 관리</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<UserAuthManagement />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { DepartmentStructure } from "./DepartmentStructure";
|
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
|
||||||
import { DepartmentMembers } from "./DepartmentMembers";
|
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
|
||||||
import type { Department } from "@/types/department";
|
import type { Department } from "@/types/department";
|
||||||
import { getCompanyList } from "@/lib/api/company";
|
import { getCompanyList } from "@/lib/api/company";
|
||||||
|
|
||||||
interface DepartmentManagementProps {
|
|
||||||
companyCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 부서 관리 메인 컴포넌트
|
* 부서 관리 메인 페이지
|
||||||
* 좌측: 부서 구조, 우측: 부서 인원
|
* 좌측: 부서 구조, 우측: 부서 인원
|
||||||
*/
|
*/
|
||||||
export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
|
export default function DepartmentManagementPage() {
|
||||||
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const companyCode = params.companyCode as string;
|
||||||
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
|
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<string>("structure");
|
const [activeTab, setActiveTab] = useState<string>("structure");
|
||||||
const [companyName, setCompanyName] = useState<string>("");
|
const [companyName, setCompanyName] = useState<string>("");
|
||||||
|
|
@ -45,7 +43,7 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
|
||||||
}, [companyCode]);
|
}, [companyCode]);
|
||||||
|
|
||||||
const handleBackToList = () => {
|
const handleBackToList = () => {
|
||||||
router.push("/admin/company");
|
router.push("/admin/userMng/companyList");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
||||||
|
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
|
||||||
|
import { CompanyTable } from "@/components/admin/CompanyTable";
|
||||||
|
import { CompanyFormModal } from "@/components/admin/CompanyFormModal";
|
||||||
|
import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog";
|
||||||
|
import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 관리 페이지
|
||||||
|
* 모든 회사 관리 기능을 통합하여 제공
|
||||||
|
*/
|
||||||
|
export default function CompanyPage() {
|
||||||
|
const {
|
||||||
|
// 데이터
|
||||||
|
companies,
|
||||||
|
searchFilter,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// 디스크 사용량 관련
|
||||||
|
diskUsageInfo,
|
||||||
|
isDiskUsageLoading,
|
||||||
|
loadDiskUsage,
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
modalState,
|
||||||
|
deleteState,
|
||||||
|
|
||||||
|
// 검색 기능
|
||||||
|
updateSearchFilter,
|
||||||
|
clearSearchFilter,
|
||||||
|
|
||||||
|
// 모달 제어
|
||||||
|
openCreateModal,
|
||||||
|
openEditModal,
|
||||||
|
closeModal,
|
||||||
|
updateFormData,
|
||||||
|
|
||||||
|
// 삭제 다이얼로그 제어
|
||||||
|
openDeleteDialog,
|
||||||
|
closeDeleteDialog,
|
||||||
|
|
||||||
|
// CRUD 작업
|
||||||
|
saveCompany,
|
||||||
|
deleteCompany,
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
clearError,
|
||||||
|
} = useCompanyManagement();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">회사 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 디스크 사용량 요약 */}
|
||||||
|
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
||||||
|
|
||||||
|
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||||
|
<CompanyToolbar
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
totalCount={companies.length}
|
||||||
|
filteredCount={companies.length}
|
||||||
|
onSearchChange={updateSearchFilter}
|
||||||
|
onSearchClear={clearSearchFilter}
|
||||||
|
onCreateClick={openCreateModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 회사 목록 테이블 */}
|
||||||
|
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} />
|
||||||
|
|
||||||
|
{/* 회사 등록/수정 모달 */}
|
||||||
|
<CompanyFormModal
|
||||||
|
modalState={modalState}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
onClose={closeModal}
|
||||||
|
onSave={saveCompany}
|
||||||
|
onFormChange={updateFormData}
|
||||||
|
onClearError={clearError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 회사 삭제 확인 다이얼로그 */}
|
||||||
|
<CompanyDeleteDialog
|
||||||
|
deleteState={deleteState}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
onClose={closeDeleteDialog}
|
||||||
|
onConfirm={deleteCompany}
|
||||||
|
onClearError={clearError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { UserManagement } from "@/components/admin/UserManagement";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자관리 페이지
|
|
||||||
* URL: /admin/userMng
|
|
||||||
*
|
|
||||||
* shadcn/ui 스타일 가이드 적용
|
|
||||||
*/
|
|
||||||
export default function UserMngPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템 사용자 계정 및 권한을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<UserManagement />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { use } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react";
|
import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react";
|
||||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { DualListBox } from "@/components/common/DualListBox";
|
import { DualListBox } from "@/components/common/DualListBox";
|
||||||
import { MenuPermissionsTable } from "./MenuPermissionsTable";
|
import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
interface RoleDetailManagementProps {
|
|
||||||
roleId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 그룹 상세 관리 컴포넌트
|
* 권한 그룹 상세 페이지
|
||||||
|
* URL: /admin/userMng/rolesList/[id]
|
||||||
*
|
*
|
||||||
* 기능:
|
* 기능:
|
||||||
* - 권한 그룹 정보 표시
|
* - 권한 그룹 멤버 관리 (Dual List Box)
|
||||||
* - 멤버 관리 (Dual List Box)
|
|
||||||
* - 메뉴 권한 설정 (CRUD 체크박스)
|
* - 메뉴 권한 설정 (CRUD 체크박스)
|
||||||
*/
|
*/
|
||||||
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
|
||||||
|
const { id: roleId } = use(params);
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
|
|
@ -236,7 +235,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
<h3 className="mb-2 text-lg font-semibold">오류 발생</h3>
|
<h3 className="mb-2 text-lg font-semibold">오류 발생</h3>
|
||||||
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
|
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
|
||||||
<Button variant="outline" onClick={() => router.push("/admin/roles")}>
|
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
|
||||||
목록으로 돌아가기
|
목록으로 돌아가기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -244,102 +243,107 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
{/* 페이지 헤더 */}
|
<div className="space-y-6 p-6">
|
||||||
<div className="space-y-2 border-b pb-4">
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/roles")} className="h-10 w-10">
|
<div className="flex items-center gap-4">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
|
||||||
</Button>
|
<ArrowLeft className="h-5 w-5" />
|
||||||
<div className="flex-1">
|
</Button>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
|
<div className="flex-1">
|
||||||
<p className="text-muted-foreground text-sm">
|
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
|
||||||
{roleGroup.authCode} • {roleGroup.companyCode}
|
<p className="text-muted-foreground text-sm">
|
||||||
</p>
|
{roleGroup.authCode} • {roleGroup.companyCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||||
|
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{roleGroup.status === "active" ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
</div>
|
||||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
|
||||||
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
{/* 탭 네비게이션 */}
|
||||||
|
<div className="flex gap-4 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("members")}
|
||||||
|
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "members"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{roleGroup.status === "active" ? "활성" : "비활성"}
|
<Users className="h-4 w-4" />
|
||||||
</span>
|
멤버 관리 ({selectedUsers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("permissions")}
|
||||||
|
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "permissions"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MenuIcon className="h-4 w-4" />
|
||||||
|
메뉴 권한 ({menuPermissions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activeTab === "members" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">멤버 관리</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">이 권한 그룹에 속한 사용자를 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSavingMembers ? "저장 중..." : "멤버 저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DualListBox
|
||||||
|
availableItems={availableUsers}
|
||||||
|
selectedItems={selectedUsers}
|
||||||
|
onSelectionChange={setSelectedUsers}
|
||||||
|
availableLabel="전체 사용자"
|
||||||
|
selectedLabel="그룹 멤버"
|
||||||
|
enableSearch
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "permissions" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">메뉴 권한 설정</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSavingPermissions ? "저장 중..." : "권한 저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MenuPermissionsTable
|
||||||
|
permissions={menuPermissions}
|
||||||
|
onPermissionsChange={setMenuPermissions}
|
||||||
|
roleGroup={roleGroup}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
<div className="flex gap-4 border-b">
|
<ScrollToTop />
|
||||||
<button
|
</div>
|
||||||
onClick={() => setActiveTab("members")}
|
|
||||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === "members"
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
멤버 관리 ({selectedUsers.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("permissions")}
|
|
||||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === "permissions"
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MenuIcon className="h-4 w-4" />
|
|
||||||
메뉴 권한 ({menuPermissions.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{activeTab === "members" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">멤버 관리</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">이 권한 그룹에 속한 사용자를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{isSavingMembers ? "저장 중..." : "멤버 저장"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DualListBox
|
|
||||||
availableItems={availableUsers}
|
|
||||||
selectedItems={selectedUsers}
|
|
||||||
onSelectionChange={setSelectedUsers}
|
|
||||||
availableLabel="전체 사용자"
|
|
||||||
selectedLabel="그룹 멤버"
|
|
||||||
enableSearch
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "permissions" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">메뉴 권한 설정</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{isSavingPermissions ? "저장 중..." : "권한 저장"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MenuPermissionsTable
|
|
||||||
permissions={menuPermissions}
|
|
||||||
onPermissionsChange={setMenuPermissions}
|
|
||||||
roleGroup={roleGroup}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
|
||||||
|
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { RoleFormModal } from "@/components/admin/RoleFormModal";
|
||||||
|
import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { companyAPI } from "@/lib/api/company";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 그룹 관리 페이지
|
||||||
|
* URL: /admin/roles
|
||||||
|
*
|
||||||
|
* shadcn/ui 스타일 가이드 적용
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
|
* - 회사별 권한 그룹 목록 조회
|
||||||
|
* - 권한 그룹 생성/수정/삭제
|
||||||
|
* - 멤버 관리 (Dual List Box)
|
||||||
|
* - 메뉴 권한 설정 (CRUD 권한)
|
||||||
|
* - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
|
||||||
|
*/
|
||||||
|
export default function RolesPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 회사 관리자 또는 최고 관리자 여부
|
||||||
|
const isAdmin =
|
||||||
|
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
|
||||||
|
currentUser?.userType === "COMPANY_ADMIN";
|
||||||
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 회사 필터 (최고 관리자 전용)
|
||||||
|
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [formModal, setFormModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
editingRole: null as RoleGroup | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteModal, setDeleteModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
role: null as RoleGroup | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 회사 목록 로드 (최고 관리자만)
|
||||||
|
const loadCompanies = useCallback(async () => {
|
||||||
|
if (!isSuperAdmin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companies = await companyAPI.getList();
|
||||||
|
setCompanies(companies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회사 목록 로드 오류:", error);
|
||||||
|
}
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadRoleGroups = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
|
||||||
|
// 회사 관리자: 자기 회사만 조회
|
||||||
|
const companyFilter =
|
||||||
|
isSuperAdmin && selectedCompany !== "all"
|
||||||
|
? selectedCompany
|
||||||
|
: isSuperAdmin
|
||||||
|
? undefined
|
||||||
|
: currentUser?.companyCode;
|
||||||
|
|
||||||
|
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
|
||||||
|
|
||||||
|
const response = await roleAPI.getList({
|
||||||
|
companyCode: companyFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRoleGroups(response.data);
|
||||||
|
console.log("권한 그룹 조회 성공:", response.data.length, "개");
|
||||||
|
} else {
|
||||||
|
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("권한 그룹 목록 로드 오류:", err);
|
||||||
|
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
|
||||||
|
}
|
||||||
|
loadRoleGroups();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
|
||||||
|
|
||||||
|
// 권한 그룹 생성 핸들러
|
||||||
|
const handleCreateRole = useCallback(() => {
|
||||||
|
setFormModal({ isOpen: true, editingRole: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 그룹 수정 핸들러
|
||||||
|
const handleEditRole = useCallback((role: RoleGroup) => {
|
||||||
|
setFormModal({ isOpen: true, editingRole: role });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 그룹 삭제 핸들러
|
||||||
|
const handleDeleteRole = useCallback((role: RoleGroup) => {
|
||||||
|
setDeleteModal({ isOpen: true, role });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 폼 모달 닫기
|
||||||
|
const handleFormModalClose = useCallback(() => {
|
||||||
|
setFormModal({ isOpen: false, editingRole: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 삭제 모달 닫기
|
||||||
|
const handleDeleteModalClose = useCallback(() => {
|
||||||
|
setDeleteModal({ isOpen: false, role: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 모달 성공 후 새로고침
|
||||||
|
const handleModalSuccess = useCallback(() => {
|
||||||
|
loadRoleGroups();
|
||||||
|
}, [loadRoleGroups]);
|
||||||
|
|
||||||
|
// 상세 페이지로 이동
|
||||||
|
const handleViewDetail = useCallback(
|
||||||
|
(role: RoleGroup) => {
|
||||||
|
router.push(`/admin/userMng/rolesList/${role.objid}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 관리자가 아니면 접근 제한
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||||
|
권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
|
뒤로 가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 영역 */}
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">권한 그룹 목록 ({roleGroups.length})</h2>
|
||||||
|
|
||||||
|
{/* 최고 관리자 전용: 회사 필터 */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="text-muted-foreground h-4 w-4" />
|
||||||
|
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
|
||||||
|
<SelectTrigger className="h-10 w-[200px]">
|
||||||
|
<SelectValue placeholder="회사 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 회사</SelectItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company.company_code} value={company.company_code}>
|
||||||
|
{company.company_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedCompany !== "all" && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleCreateRole} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
권한 그룹 생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 권한 그룹 목록 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-card rounded-lg border p-12 shadow-sm">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||||
|
<p className="text-muted-foreground text-sm">권한 그룹 목록을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : roleGroups.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">등록된 권한 그룹이 없습니다.</p>
|
||||||
|
<p className="text-muted-foreground text-xs">권한 그룹을 생성하여 멤버를 관리해보세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{roleGroups.map((role) => (
|
||||||
|
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
|
||||||
|
{/* 헤더 (클릭 시 상세 페이지) */}
|
||||||
|
<div
|
||||||
|
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
|
||||||
|
onClick={() => handleViewDetail(role)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-base font-semibold">{role.authName}</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.authCode}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role.status === "active" ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{/* 최고 관리자는 회사명 표시 */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">회사</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
멤버 수
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{role.memberCount || 0}명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Menu className="h-3 w-3" />
|
||||||
|
메뉴 권한
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{role.menuCount || 0}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-2 border-t p-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditRole(role);
|
||||||
|
}}
|
||||||
|
className="flex-1 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteRole(role);
|
||||||
|
}}
|
||||||
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달들 */}
|
||||||
|
<RoleFormModal
|
||||||
|
isOpen={formModal.isOpen}
|
||||||
|
onClose={handleFormModalClose}
|
||||||
|
onSuccess={handleModalSuccess}
|
||||||
|
editingRole={formModal.editingRole}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleDeleteModal
|
||||||
|
isOpen={deleteModal.isOpen}
|
||||||
|
onClose={handleDeleteModalClose}
|
||||||
|
onSuccess={handleModalSuccess}
|
||||||
|
role={deleteModal.role}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { UserAuthTable } from "@/components/admin/UserAuthTable";
|
||||||
|
import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal";
|
||||||
|
import { userAPI } from "@/lib/api/user";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 권한 관리 페이지
|
||||||
|
* URL: /admin/userAuth
|
||||||
|
*
|
||||||
|
* 최고 관리자만 접근 가능
|
||||||
|
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
|
||||||
|
*/
|
||||||
|
export default function UserAuthPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
// 최고 관리자 여부
|
||||||
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [paginationInfo, setPaginationInfo] = useState({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
totalItems: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 권한 변경 모달
|
||||||
|
const [authEditModal, setAuthEditModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
user: null as any | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadUsers = useCallback(
|
||||||
|
async (page: number = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await userAPI.getList({
|
||||||
|
page,
|
||||||
|
size: paginationInfo.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setUsers(response.data);
|
||||||
|
setPaginationInfo({
|
||||||
|
currentPage: response.currentPage || page,
|
||||||
|
pageSize: response.pageSize || paginationInfo.pageSize,
|
||||||
|
totalItems: response.total || 0,
|
||||||
|
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("사용자 목록 로드 오류:", err);
|
||||||
|
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[paginationInfo.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 변경 핸들러
|
||||||
|
const handleEditAuth = (user: any) => {
|
||||||
|
setAuthEditModal({
|
||||||
|
isOpen: true,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 변경 모달 닫기
|
||||||
|
const handleAuthEditClose = () => {
|
||||||
|
setAuthEditModal({
|
||||||
|
isOpen: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 변경 성공
|
||||||
|
const handleAuthEditSuccess = () => {
|
||||||
|
loadUsers(paginationInfo.currentPage);
|
||||||
|
handleAuthEditClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
loadUsers(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 최고 관리자가 아닌 경우
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">사용자 권한 관리</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||||
|
권한 관리는 최고 관리자만 접근할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
|
뒤로 가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">사용자 권한 관리</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 권한 테이블 */}
|
||||||
|
<UserAuthTable
|
||||||
|
users={users}
|
||||||
|
isLoading={isLoading}
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onEditAuth={handleEditAuth}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 권한 변경 모달 */}
|
||||||
|
<UserAuthEditModal
|
||||||
|
isOpen={authEditModal.isOpen}
|
||||||
|
onClose={handleAuthEditClose}
|
||||||
|
onSuccess={handleAuthEditSuccess}
|
||||||
|
user={authEditModal.user}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useUserManagement } from "@/hooks/useUserManagement";
|
||||||
|
import { UserToolbar } from "@/components/admin/UserToolbar";
|
||||||
|
import { UserTable } from "@/components/admin/UserTable";
|
||||||
|
import { Pagination } from "@/components/common/Pagination";
|
||||||
|
import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
|
||||||
|
import { UserFormModal } from "@/components/admin/UserFormModal";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자관리 페이지
|
||||||
|
* URL: /admin/userMng
|
||||||
|
*
|
||||||
|
* shadcn/ui 스타일 가이드 적용
|
||||||
|
* - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
|
||||||
|
* - 실제 데이터베이스와 연동되어 작동
|
||||||
|
*/
|
||||||
|
export default function UserMngPage() {
|
||||||
|
const {
|
||||||
|
// 데이터
|
||||||
|
users,
|
||||||
|
searchFilter,
|
||||||
|
isLoading,
|
||||||
|
isSearching,
|
||||||
|
error,
|
||||||
|
paginationInfo,
|
||||||
|
|
||||||
|
// 검색 기능
|
||||||
|
updateSearchFilter,
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
|
|
||||||
|
// 액션 핸들러
|
||||||
|
handleStatusToggle,
|
||||||
|
|
||||||
|
// 유틸리티
|
||||||
|
clearError,
|
||||||
|
refreshData,
|
||||||
|
} = useUserManagement();
|
||||||
|
|
||||||
|
// 비밀번호 초기화 모달 상태
|
||||||
|
const [passwordResetModal, setPasswordResetModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
userId: null as string | null,
|
||||||
|
userName: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 등록/수정 모달 상태
|
||||||
|
const [userFormModal, setUserFormModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
editingUser: null as any | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 등록 핸들러
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
setUserFormModal({
|
||||||
|
isOpen: true,
|
||||||
|
editingUser: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 수정 핸들러
|
||||||
|
const handleEditUser = (user: any) => {
|
||||||
|
setUserFormModal({
|
||||||
|
isOpen: true,
|
||||||
|
editingUser: user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 등록/수정 모달 닫기
|
||||||
|
const handleUserFormClose = () => {
|
||||||
|
setUserFormModal({
|
||||||
|
isOpen: false,
|
||||||
|
editingUser: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 등록/수정 성공 핸들러
|
||||||
|
const handleUserFormSuccess = () => {
|
||||||
|
refreshData();
|
||||||
|
handleUserFormClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 초기화 핸들러
|
||||||
|
const handlePasswordReset = (userId: string, userName: string) => {
|
||||||
|
setPasswordResetModal({
|
||||||
|
isOpen: true,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 초기화 모달 닫기
|
||||||
|
const handlePasswordResetClose = () => {
|
||||||
|
setPasswordResetModal({
|
||||||
|
isOpen: false,
|
||||||
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 초기화 성공 핸들러
|
||||||
|
const handlePasswordResetSuccess = () => {
|
||||||
|
handlePasswordResetClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">시스템 사용자 계정 및 권한을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||||
|
<UserToolbar
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
totalCount={paginationInfo.totalItems}
|
||||||
|
isSearching={isSearching}
|
||||||
|
onSearchChange={updateSearchFilter}
|
||||||
|
onCreateClick={handleCreateUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 목록 테이블 */}
|
||||||
|
<UserTable
|
||||||
|
users={users}
|
||||||
|
isLoading={isLoading}
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onStatusToggle={handleStatusToggle}
|
||||||
|
onPasswordReset={handlePasswordReset}
|
||||||
|
onEdit={handleEditUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!isLoading && users.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
className="mt-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 등록/수정 모달 */}
|
||||||
|
<UserFormModal
|
||||||
|
isOpen={userFormModal.isOpen}
|
||||||
|
onClose={handleUserFormClose}
|
||||||
|
onSuccess={handleUserFormSuccess}
|
||||||
|
editingUser={userFormModal.editingUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 비밀번호 초기화 모달 */}
|
||||||
|
<UserPasswordResetModal
|
||||||
|
isOpen={passwordResetModal.isOpen}
|
||||||
|
onClose={handlePasswordResetClose}
|
||||||
|
userId={passwordResetModal.userId}
|
||||||
|
userName={passwordResetModal.userName}
|
||||||
|
onSuccess={handlePasswordResetSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -142,7 +142,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
{/* 편집 버튼 *\/}
|
{/* 편집 버튼 *\/}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
|
router.push(`/admin/screenMng/dashboardList?load=${resolvedParams.dashboardId}`);
|
||||||
}}
|
}}
|
||||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ export default function DashboardListPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin/dashboard"
|
href="/admin/screenMng/dashboardList"
|
||||||
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
➕ 새 대시보드 만들기
|
➕ 새 대시보드 만들기
|
||||||
|
|
@ -185,7 +185,7 @@ export default function DashboardListPage() {
|
||||||
</p>
|
</p>
|
||||||
{!searchTerm && (
|
{!searchTerm && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/dashboard"
|
href="/admin/screenMng/dashboardList"
|
||||||
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
➕ 대시보드 만들기
|
➕ 대시보드 만들기
|
||||||
|
|
@ -251,7 +251,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||||
보기
|
보기
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/dashboard?load=${dashboard.id}`}
|
href={`/admin/screenMng/dashboardList?load=${dashboard.id}`}
|
||||||
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
편집
|
편집
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
|
||||||
export default function MainPage() {
|
export default function MainPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4">
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
{/* Welcome Message */}
|
{/* Welcome Message */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
|
||||||
import { CompanyToolbar } from "./CompanyToolbar";
|
|
||||||
import { CompanyTable } from "./CompanyTable";
|
|
||||||
import { CompanyFormModal } from "./CompanyFormModal";
|
|
||||||
import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
|
|
||||||
import { DiskUsageSummary } from "./DiskUsageSummary";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회사 관리 메인 컴포넌트
|
|
||||||
* 모든 회사 관리 기능을 통합하여 제공
|
|
||||||
*/
|
|
||||||
export function CompanyManagement() {
|
|
||||||
const {
|
|
||||||
// 데이터
|
|
||||||
companies,
|
|
||||||
searchFilter,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// 디스크 사용량 관련
|
|
||||||
diskUsageInfo,
|
|
||||||
isDiskUsageLoading,
|
|
||||||
loadDiskUsage,
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
modalState,
|
|
||||||
deleteState,
|
|
||||||
|
|
||||||
// 검색 기능
|
|
||||||
updateSearchFilter,
|
|
||||||
clearSearchFilter,
|
|
||||||
|
|
||||||
// 모달 제어
|
|
||||||
openCreateModal,
|
|
||||||
openEditModal,
|
|
||||||
closeModal,
|
|
||||||
updateFormData,
|
|
||||||
|
|
||||||
// 삭제 다이얼로그 제어
|
|
||||||
openDeleteDialog,
|
|
||||||
closeDeleteDialog,
|
|
||||||
|
|
||||||
// CRUD 작업
|
|
||||||
saveCompany,
|
|
||||||
deleteCompany,
|
|
||||||
|
|
||||||
// 에러 처리
|
|
||||||
clearError,
|
|
||||||
} = useCompanyManagement();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 디스크 사용량 요약 */}
|
|
||||||
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
|
||||||
|
|
||||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
|
||||||
<CompanyToolbar
|
|
||||||
searchFilter={searchFilter}
|
|
||||||
totalCount={companies.length} // 실제 API에서 가져온 데이터 개수 사용
|
|
||||||
filteredCount={companies.length}
|
|
||||||
onSearchChange={updateSearchFilter}
|
|
||||||
onSearchClear={clearSearchFilter}
|
|
||||||
onCreateClick={openCreateModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 회사 목록 테이블 */}
|
|
||||||
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} />
|
|
||||||
|
|
||||||
{/* 회사 등록/수정 모달 */}
|
|
||||||
<CompanyFormModal
|
|
||||||
modalState={modalState}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
onClose={closeModal}
|
|
||||||
onSave={saveCompany}
|
|
||||||
onFormChange={updateFormData}
|
|
||||||
onClearError={clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 회사 삭제 확인 다이얼로그 */}
|
|
||||||
<CompanyDeleteDialog
|
|
||||||
deleteState={deleteState}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
onClose={closeDeleteDialog}
|
|
||||||
onConfirm={deleteCompany}
|
|
||||||
onClearError={clearError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Building2, Search } from "lucide-react";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { logger } from "@/lib/utils/logger";
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
company_code: string;
|
||||||
|
company_name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanySwitcherProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
isOpen?: boolean; // Dialog 열림 상태 (AppLayout에서 전달)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WACE 관리자 전용: 회사 선택 및 전환 컴포넌트
|
||||||
|
*
|
||||||
|
* - WACE 관리자(company_code = "*", userType = "SUPER_ADMIN")만 표시
|
||||||
|
* - 회사 선택 시 해당 회사로 전환하여 시스템 사용
|
||||||
|
* - JWT 토큰 재발급으로 company_code 변경
|
||||||
|
*/
|
||||||
|
export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProps = {}) {
|
||||||
|
const { user, switchCompany } = useAuth();
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [filteredCompanies, setFilteredCompanies] = useState<Company[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// WACE 관리자 권한 체크 (userType만 확인)
|
||||||
|
const isWaceAdmin = user?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 현재 선택된 회사명 표시
|
||||||
|
const currentCompanyName = React.useMemo(() => {
|
||||||
|
if (!user?.companyCode) return "로딩 중...";
|
||||||
|
|
||||||
|
if (user.companyCode === "*") {
|
||||||
|
return "WACE (최고 관리자)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// companies 배열에서 현재 회사 찾기
|
||||||
|
const currentCompany = companies.find(c => c.company_code === user.companyCode);
|
||||||
|
return currentCompany?.company_name || user.companyCode;
|
||||||
|
}, [user?.companyCode, companies]);
|
||||||
|
|
||||||
|
// 회사 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWaceAdmin && isOpen) {
|
||||||
|
fetchCompanies();
|
||||||
|
}
|
||||||
|
}, [isWaceAdmin, isOpen]);
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchText.trim() === "") {
|
||||||
|
setFilteredCompanies(companies);
|
||||||
|
} else {
|
||||||
|
const filtered = companies.filter(company =>
|
||||||
|
company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
company.company_code.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredCompanies(filtered);
|
||||||
|
}
|
||||||
|
}, [searchText, companies]);
|
||||||
|
|
||||||
|
const fetchCompanies = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiClient.get("/admin/companies/db");
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
// 활성 상태의 회사만 필터링 + company_code="*" 제외 (WACE는 별도 추가)
|
||||||
|
const activeCompanies = response.data.data
|
||||||
|
.filter((c: Company) => c.company_code !== "*") // DB의 "*" 제외
|
||||||
|
.filter((c: Company) => c.status === "active" || !c.status)
|
||||||
|
.sort((a: Company, b: Company) => a.company_name.localeCompare(b.company_name));
|
||||||
|
|
||||||
|
// WACE 복귀 옵션 추가
|
||||||
|
const companiesWithWace: Company[] = [
|
||||||
|
{
|
||||||
|
company_code: "*",
|
||||||
|
company_name: "WACE (최고 관리자)",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
...activeCompanies,
|
||||||
|
];
|
||||||
|
|
||||||
|
setCompanies(companiesWithWace);
|
||||||
|
setFilteredCompanies(companiesWithWace);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("회사 목록 조회 실패", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompanySwitch = async (companyCode: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await switchCompany(companyCode);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.message || "회사 전환에 실패했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("회사 전환 성공", { companyCode });
|
||||||
|
|
||||||
|
// 즉시 페이지 새로고침 (토큰이 이미 저장됨)
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("회사 전환 실패", error);
|
||||||
|
alert(error.message || "회사 전환 중 오류가 발생했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// WACE 관리자가 아니면 렌더링하지 않음
|
||||||
|
if (!isWaceAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 현재 회사 정보 */}
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">현재 관리 회사</p>
|
||||||
|
<p className="text-sm font-semibold">{currentCompanyName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="회사명 또는 코드 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 목록 */}
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : filteredCompanies.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredCompanies.map((company) => (
|
||||||
|
<div
|
||||||
|
key={company.company_code}
|
||||||
|
className={`flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||||
|
company.company_code === user?.companyCode
|
||||||
|
? "bg-accent/50 font-semibold"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCompanySwitch(company.company_code)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{company.company_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{company.company_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{company.company_code === user?.companyCode && (
|
||||||
|
<span className="text-xs text-primary">현재</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
|
|
||||||
// 부서 관리 페이지로 이동
|
// 부서 관리 페이지로 이동
|
||||||
const handleManageDepartments = (company: Company) => {
|
const handleManageDepartments = (company: Company) => {
|
||||||
router.push(`/admin/company/${company.company_code}/departments`);
|
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디스크 사용량 포맷팅 함수
|
// 디스크 사용량 포맷팅 함수
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,288 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch";
|
|
||||||
|
|
||||||
export default function MonitoringDashboard() {
|
|
||||||
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMonitoringData();
|
|
||||||
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
if (autoRefresh) {
|
|
||||||
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [autoRefresh]);
|
|
||||||
|
|
||||||
const loadMonitoringData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await BatchAPI.getBatchMonitoring();
|
|
||||||
setMonitoring(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("모니터링 데이터 조회 오류:", error);
|
|
||||||
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
loadMonitoringData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoRefresh = () => {
|
|
||||||
setAutoRefresh(!autoRefresh);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
|
||||||
case 'failed':
|
|
||||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
|
||||||
case 'running':
|
|
||||||
return <Play className="h-4 w-4 text-blue-500" />;
|
|
||||||
case 'pending':
|
|
||||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
|
||||||
default:
|
|
||||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const variants = {
|
|
||||||
completed: "bg-green-100 text-green-800",
|
|
||||||
failed: "bg-destructive/20 text-red-800",
|
|
||||||
running: "bg-primary/20 text-blue-800",
|
|
||||||
pending: "bg-yellow-100 text-yellow-800",
|
|
||||||
cancelled: "bg-gray-100 text-gray-800",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
completed: "완료",
|
|
||||||
failed: "실패",
|
|
||||||
running: "실행 중",
|
|
||||||
pending: "대기 중",
|
|
||||||
cancelled: "취소됨",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
|
||||||
{labels[status as keyof typeof labels] || status}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (ms: number) => {
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
return `${(ms / 60000).toFixed(1)}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSuccessRate = () => {
|
|
||||||
if (!monitoring) return 0;
|
|
||||||
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
|
||||||
if (total === 0) return 100;
|
|
||||||
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!monitoring) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
|
||||||
<p>모니터링 데이터를 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={toggleAutoRefresh}
|
|
||||||
className={autoRefresh ? "bg-accent text-primary" : ""}
|
|
||||||
>
|
|
||||||
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
|
||||||
자동 새로고침
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
|
||||||
<div className="text-2xl">📋</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
활성: {monitoring.active_jobs}개
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
|
||||||
<div className="text-2xl">🔄</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
현재 실행 중인 작업
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
|
||||||
<div className="text-2xl">✅</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
성공률: {getSuccessRate()}%
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
|
||||||
<div className="text-2xl">❌</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
주의가 필요한 작업
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 성공률 진행바 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>성공: {monitoring.successful_jobs_today}건</span>
|
|
||||||
<span>실패: {monitoring.failed_jobs_today}건</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={getSuccessRate()} className="h-2" />
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
|
||||||
{getSuccessRate()}% 성공률
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 최근 실행 이력 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{monitoring.recent_executions.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
최근 실행 이력이 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>상태</TableHead>
|
|
||||||
<TableHead>작업 ID</TableHead>
|
|
||||||
<TableHead>시작 시간</TableHead>
|
|
||||||
<TableHead>완료 시간</TableHead>
|
|
||||||
<TableHead>실행 시간</TableHead>
|
|
||||||
<TableHead>오류 메시지</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{monitoring.recent_executions.map((execution) => (
|
|
||||||
<TableRow key={execution.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getStatusIcon(execution.execution_status)}
|
|
||||||
{getStatusBadge(execution.execution_status)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{execution.started_at
|
|
||||||
? new Date(execution.started_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{execution.completed_at
|
|
||||||
? new Date(execution.completed_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{execution.execution_time_ms
|
|
||||||
? formatDuration(execution.execution_time_ms)
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-xs">
|
|
||||||
{execution.error_message ? (
|
|
||||||
<span className="text-destructive text-sm truncate block">
|
|
||||||
{execution.error_message}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
|
|
||||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { RoleFormModal } from "./RoleFormModal";
|
|
||||||
import { RoleDeleteModal } from "./RoleDeleteModal";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { companyAPI } from "@/lib/api/company";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 관리 메인 컴포넌트
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 권한 그룹 목록 조회 (회사별)
|
|
||||||
* - 권한 그룹 생성/수정/삭제
|
|
||||||
* - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
|
|
||||||
*/
|
|
||||||
export function RoleManagement() {
|
|
||||||
const { user: currentUser } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// 회사 관리자 또는 최고 관리자 여부
|
|
||||||
const isAdmin =
|
|
||||||
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
|
|
||||||
currentUser?.userType === "COMPANY_ADMIN";
|
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 회사 필터 (최고 관리자 전용)
|
|
||||||
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
|
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [formModal, setFormModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
editingRole: null as RoleGroup | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
role: null as RoleGroup | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 회사 목록 로드 (최고 관리자만)
|
|
||||||
const loadCompanies = useCallback(async () => {
|
|
||||||
if (!isSuperAdmin) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const companies = await companyAPI.getList();
|
|
||||||
setCompanies(companies);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("회사 목록 로드 오류:", error);
|
|
||||||
}
|
|
||||||
}, [isSuperAdmin]);
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
const loadRoleGroups = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
|
|
||||||
// 회사 관리자: 자기 회사만 조회
|
|
||||||
const companyFilter =
|
|
||||||
isSuperAdmin && selectedCompany !== "all"
|
|
||||||
? selectedCompany
|
|
||||||
: isSuperAdmin
|
|
||||||
? undefined
|
|
||||||
: currentUser?.companyCode;
|
|
||||||
|
|
||||||
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
|
|
||||||
|
|
||||||
const response = await roleAPI.getList({
|
|
||||||
companyCode: companyFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setRoleGroups(response.data);
|
|
||||||
console.log("권한 그룹 조회 성공:", response.data.length, "개");
|
|
||||||
} else {
|
|
||||||
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("권한 그룹 목록 로드 오류:", err);
|
|
||||||
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAdmin) {
|
|
||||||
if (isSuperAdmin) {
|
|
||||||
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
|
|
||||||
}
|
|
||||||
loadRoleGroups();
|
|
||||||
} else {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
|
|
||||||
|
|
||||||
// 권한 그룹 생성 핸들러
|
|
||||||
const handleCreateRole = useCallback(() => {
|
|
||||||
setFormModal({ isOpen: true, editingRole: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 권한 그룹 수정 핸들러
|
|
||||||
const handleEditRole = useCallback((role: RoleGroup) => {
|
|
||||||
setFormModal({ isOpen: true, editingRole: role });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 권한 그룹 삭제 핸들러
|
|
||||||
const handleDeleteRole = useCallback((role: RoleGroup) => {
|
|
||||||
setDeleteModal({ isOpen: true, role });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 폼 모달 닫기
|
|
||||||
const handleFormModalClose = useCallback(() => {
|
|
||||||
setFormModal({ isOpen: false, editingRole: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 삭제 모달 닫기
|
|
||||||
const handleDeleteModalClose = useCallback(() => {
|
|
||||||
setDeleteModal({ isOpen: false, role: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 모달 성공 후 새로고침
|
|
||||||
const handleModalSuccess = useCallback(() => {
|
|
||||||
loadRoleGroups();
|
|
||||||
}, [loadRoleGroups]);
|
|
||||||
|
|
||||||
// 상세 페이지로 이동
|
|
||||||
const handleViewDetail = useCallback(
|
|
||||||
(role: RoleGroup) => {
|
|
||||||
router.push(`/admin/roles/${role.objid}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 관리자가 아니면 접근 제한
|
|
||||||
if (!isAdmin) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
|
||||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
|
||||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
|
||||||
권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
|
||||||
뒤로 가기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
|
||||||
aria-label="에러 메시지 닫기"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 액션 버튼 영역 */}
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h2 className="text-xl font-semibold">권한 그룹 목록 ({roleGroups.length})</h2>
|
|
||||||
|
|
||||||
{/* 최고 관리자 전용: 회사 필터 */}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="text-muted-foreground h-4 w-4" />
|
|
||||||
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
|
|
||||||
<SelectTrigger className="h-10 w-[200px]">
|
|
||||||
<SelectValue placeholder="회사 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체 회사</SelectItem>
|
|
||||||
{companies.map((company) => (
|
|
||||||
<SelectItem key={company.company_code} value={company.company_code}>
|
|
||||||
{company.company_name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedCompany !== "all" && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={handleCreateRole} className="gap-2">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
권한 그룹 생성
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 권한 그룹 목록 */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="bg-card rounded-lg border p-12 shadow-sm">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4">
|
|
||||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
|
||||||
<p className="text-muted-foreground text-sm">권한 그룹 목록을 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : roleGroups.length === 0 ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">등록된 권한 그룹이 없습니다.</p>
|
|
||||||
<p className="text-muted-foreground text-xs">권한 그룹을 생성하여 멤버를 관리해보세요.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{roleGroups.map((role) => (
|
|
||||||
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
|
|
||||||
{/* 헤더 (클릭 시 상세 페이지) */}
|
|
||||||
<div
|
|
||||||
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
|
|
||||||
onClick={() => handleViewDetail(role)}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-base font-semibold">{role.authName}</h3>
|
|
||||||
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.authCode}</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{role.status === "active" ? "활성" : "비활성"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
{/* 최고 관리자는 회사명 표시 */}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">회사</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
멤버 수
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{role.memberCount || 0}명</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<Menu className="h-3 w-3" />
|
|
||||||
메뉴 권한
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{role.menuCount || 0}개</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
|
||||||
<div className="flex gap-2 border-t p-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditRole(role);
|
|
||||||
}}
|
|
||||||
className="flex-1 gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteRole(role);
|
|
||||||
}}
|
|
||||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 모달들 */}
|
|
||||||
<RoleFormModal
|
|
||||||
isOpen={formModal.isOpen}
|
|
||||||
onClose={handleFormModalClose}
|
|
||||||
onSuccess={handleModalSuccess}
|
|
||||||
editingRole={formModal.editingRole}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RoleDeleteModal
|
|
||||||
isOpen={deleteModal.isOpen}
|
|
||||||
onClose={handleDeleteModalClose}
|
|
||||||
onSuccess={handleModalSuccess}
|
|
||||||
role={deleteModal.role}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -91,7 +91,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
<Label className="text-foreground text-xs font-medium">외부 커넥션</Label>
|
<Label className="text-foreground text-xs font-medium">외부 커넥션</Label>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/external-connections");
|
router.push("/admin/automaticMng/exconList");
|
||||||
}}
|
}}
|
||||||
className="text-primary hover:text-primary flex items-center gap-1 text-[11px] transition-colors"
|
className="text-primary hover:text-primary flex items-center gap-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
|
|
@ -124,7 +124,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
<div className="text-warning mb-1 text-xs">등록된 커넥션이 없습니다</div>
|
<div className="text-warning mb-1 text-xs">등록된 커넥션이 없습니다</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/external-connections");
|
router.push("/admin/automaticMng/exconList");
|
||||||
}}
|
}}
|
||||||
className="text-warning text-[11px] underline hover:no-underline"
|
className="text-warning text-[11px] underline hover:no-underline"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@ import {
|
||||||
UserCheck,
|
UserCheck,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
|
Building2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useProfile } from "@/hooks/useProfile";
|
import { useProfile } from "@/hooks/useProfile";
|
||||||
import { MenuItem } from "@/lib/api/menu";
|
import { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ProfileModal } from "./ProfileModal";
|
import { ProfileModal } from "./ProfileModal";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
@ -35,6 +37,14 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
|
||||||
|
|
||||||
// useAuth의 UserInfo 타입을 확장
|
// useAuth의 UserInfo 타입을 확장
|
||||||
interface ExtendedUserInfo {
|
interface ExtendedUserInfo {
|
||||||
|
|
@ -206,11 +216,38 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { user, logout, refreshUserData } = useAuth();
|
const { user, logout, refreshUserData, switchCompany } = useAuth();
|
||||||
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -333,11 +370,32 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모드 전환 핸들러
|
// 모드 전환 핸들러
|
||||||
const handleModeSwitch = () => {
|
const handleModeSwitch = async () => {
|
||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
|
// 관리자 → 사용자 모드: 선택한 회사 유지
|
||||||
router.push("/main");
|
router.push("/main");
|
||||||
} else {
|
} 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>
|
</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 모드 전환 버튼 (관리자만) */}
|
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
||||||
(user as ExtendedUserInfo)?.userType === "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
|
<Button
|
||||||
onClick={handleModeSwitch}
|
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 ${
|
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
onSave={saveProfile}
|
onSave={saveProfile}
|
||||||
onAlertClose={closeAlert}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ export default function MailDetailModal({
|
||||||
originalDate: mail.date,
|
originalDate: mail.date,
|
||||||
originalBody: mail.body,
|
originalBody: mail.body,
|
||||||
};
|
};
|
||||||
router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
|
router.push(`/admin/automaticMng/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -270,7 +270,7 @@ export default function MailDetailModal({
|
||||||
originalBody: mail.body,
|
originalBody: mail.body,
|
||||||
originalAttachments: mail.attachments,
|
originalAttachments: mail.attachments,
|
||||||
};
|
};
|
||||||
router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
|
router.push(`/admin/automaticMng/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function ReportListTable({
|
||||||
|
|
||||||
// 수정
|
// 수정
|
||||||
const handleEdit = (reportId: string) => {
|
const handleEdit = (reportId: string) => {
|
||||||
router.push(`/admin/report/designer/${reportId}`);
|
router.push(`/admin/screenMng/reportList/designer/${reportId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 복사
|
// 복사
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
selectedComponentIds,
|
selectedComponentIds,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
selectMultipleComponents,
|
||||||
updateComponent,
|
updateComponent,
|
||||||
getQueryResult,
|
getQueryResult,
|
||||||
snapValueToGrid,
|
snapValueToGrid,
|
||||||
|
|
@ -178,20 +179,192 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
margins,
|
margins,
|
||||||
layoutConfig,
|
layoutConfig,
|
||||||
currentPageId,
|
currentPageId,
|
||||||
|
duplicateAtPosition,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||||
const componentRef = useRef<HTMLDivElement>(null);
|
const componentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Alt+드래그 복제를 위한 상태
|
||||||
|
const [isAltDuplicating, setIsAltDuplicating] = useState(false);
|
||||||
|
const duplicatedIdsRef = useRef<string[]>([]);
|
||||||
|
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
|
||||||
|
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
|
// 인라인 편집 상태
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const isSelected = selectedComponentId === component.id;
|
const isSelected = selectedComponentId === component.id;
|
||||||
const isMultiSelected = selectedComponentIds.includes(component.id);
|
const isMultiSelected = selectedComponentIds.includes(component.id);
|
||||||
const isLocked = component.locked === true;
|
const isLocked = component.locked === true;
|
||||||
const isGrouped = !!component.groupId;
|
const isGrouped = !!component.groupId;
|
||||||
|
|
||||||
|
// 표시할 값 결정
|
||||||
|
const getDisplayValue = (): string => {
|
||||||
|
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
|
||||||
|
if (component.queryId && component.fieldName) {
|
||||||
|
const queryResult = getQueryResult(component.queryId);
|
||||||
|
|
||||||
|
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
|
||||||
|
if (queryResult && queryResult.rows.length > 0) {
|
||||||
|
const firstRow = queryResult.rows[0];
|
||||||
|
const value = firstRow[component.fieldName];
|
||||||
|
|
||||||
|
// 값이 있으면 문자열로 변환하여 반환
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행 결과가 없거나 값이 없으면 필드명 표시
|
||||||
|
return `{${component.fieldName}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값이 있으면 기본값 표시
|
||||||
|
if (component.defaultValue) {
|
||||||
|
return component.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 둘 다 없으면 타입에 따라 기본 텍스트
|
||||||
|
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절
|
||||||
|
const fitTextToContent = () => {
|
||||||
|
if (isLocked) return;
|
||||||
|
if (component.type !== "text" && component.type !== "label") return;
|
||||||
|
|
||||||
|
const minWidth = 50;
|
||||||
|
const minHeight = 30;
|
||||||
|
|
||||||
|
// 여백을 px로 변환
|
||||||
|
const marginRightPx = margins.right * MM_TO_PX;
|
||||||
|
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||||
|
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||||
|
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||||
|
|
||||||
|
// 최대 크기 (여백 고려)
|
||||||
|
const maxWidth = canvasWidthPx - marginRightPx - component.x;
|
||||||
|
const maxHeight = canvasHeightPx - marginBottomPx - component.y;
|
||||||
|
|
||||||
|
const displayValue = getDisplayValue();
|
||||||
|
const fontSize = component.fontSize || 14;
|
||||||
|
|
||||||
|
// 줄바꿈으로 분리하여 각 줄의 너비 측정
|
||||||
|
const lines = displayValue.split("\n");
|
||||||
|
let maxLineWidth = 0;
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const measureEl = document.createElement("span");
|
||||||
|
measureEl.style.position = "absolute";
|
||||||
|
measureEl.style.visibility = "hidden";
|
||||||
|
measureEl.style.whiteSpace = "nowrap";
|
||||||
|
measureEl.style.fontSize = `${fontSize}px`;
|
||||||
|
measureEl.style.fontWeight = component.fontWeight || "normal";
|
||||||
|
measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
|
||||||
|
measureEl.textContent = line || " "; // 빈 줄은 공백으로
|
||||||
|
document.body.appendChild(measureEl);
|
||||||
|
|
||||||
|
const lineWidth = measureEl.getBoundingClientRect().width;
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||||||
|
document.body.removeChild(measureEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트 padding (p-2 = 8px * 2) + 여유분
|
||||||
|
const horizontalPadding = 24;
|
||||||
|
const verticalPadding = 20;
|
||||||
|
|
||||||
|
// 줄 높이 계산 (font-size * line-height 약 1.5)
|
||||||
|
const lineHeight = fontSize * 1.5;
|
||||||
|
const totalHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
|
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
|
||||||
|
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
|
||||||
|
|
||||||
|
const newWidth = Math.max(minWidth, finalWidth);
|
||||||
|
const newHeight = Math.max(minHeight, finalHeight);
|
||||||
|
|
||||||
|
// 크기 업데이트
|
||||||
|
updateComponent(component.id, {
|
||||||
|
width: snapValueToGrid(newWidth),
|
||||||
|
height: snapValueToGrid(newHeight),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입)
|
||||||
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||||
|
if (component.type !== "text" && component.type !== "label") return;
|
||||||
|
if (isLocked) return; // 잠긴 컴포넌트는 편집 불가
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 인라인 편집 모드 진입
|
||||||
|
setEditValue(component.defaultValue || "");
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 시작 시 textarea에 포커스
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.select();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
// 선택 해제 시 편집 모드 종료를 위한 ref
|
||||||
|
const editValueRef = useRef(editValue);
|
||||||
|
const isEditingRef = useRef(isEditing);
|
||||||
|
editValueRef.current = editValue;
|
||||||
|
isEditingRef.current = isEditing;
|
||||||
|
|
||||||
|
// 선택 해제 시 편집 모드 종료 (저장 후 종료)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSelected && !isMultiSelected && isEditingRef.current) {
|
||||||
|
// 현재 편집 값으로 저장
|
||||||
|
if (editValueRef.current !== component.defaultValue) {
|
||||||
|
updateComponent(component.id, { defaultValue: editValueRef.current });
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]);
|
||||||
|
|
||||||
|
// 인라인 편집 저장
|
||||||
|
const handleEditSave = () => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
updateComponent(component.id, {
|
||||||
|
defaultValue: editValue,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 취소
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 키보드 핸들러
|
||||||
|
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEditCancel();
|
||||||
|
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
// Enter: 저장 (Shift+Enter는 줄바꿈)
|
||||||
|
e.preventDefault();
|
||||||
|
handleEditSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 드래그 시작
|
// 드래그 시작
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
// 편집 모드에서는 드래그 비활성화
|
||||||
|
if (isEditing) return;
|
||||||
|
|
||||||
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -209,16 +382,83 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
|
|
||||||
// Ctrl/Cmd 키 감지 (다중 선택)
|
// Ctrl/Cmd 키 감지 (다중 선택)
|
||||||
const isMultiSelect = e.ctrlKey || e.metaKey;
|
const isMultiSelect = e.ctrlKey || e.metaKey;
|
||||||
|
// Alt 키 감지 (복제 드래그)
|
||||||
|
const isAltPressed = e.altKey;
|
||||||
|
|
||||||
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
|
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
|
||||||
if (isGrouped && !isMultiSelect) {
|
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
|
||||||
const groupMembers = components.filter((c) => c.groupId === component.groupId);
|
|
||||||
const groupMemberIds = groupMembers.map((c) => c.id);
|
if (!isPartOfMultiSelection) {
|
||||||
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
|
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
|
||||||
selectComponent(groupMemberIds[0], false);
|
if (isGrouped && !isMultiSelect) {
|
||||||
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
|
const groupMembers = components.filter((c) => c.groupId === component.groupId);
|
||||||
} else {
|
const groupMemberIds = groupMembers.map((c) => c.id);
|
||||||
selectComponent(component.id, isMultiSelect);
|
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
|
||||||
|
selectComponent(groupMemberIds[0], false);
|
||||||
|
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
|
||||||
|
} else {
|
||||||
|
selectComponent(component.id, isMultiSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+드래그: 복제 모드
|
||||||
|
if (isAltPressed) {
|
||||||
|
// 복제할 컴포넌트 ID 목록 결정
|
||||||
|
let idsToClone: string[] = [];
|
||||||
|
|
||||||
|
if (isPartOfMultiSelection) {
|
||||||
|
// 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제
|
||||||
|
idsToClone = selectedComponentIds.filter((id) => {
|
||||||
|
const c = components.find((comp) => comp.id === id);
|
||||||
|
return c && !c.locked;
|
||||||
|
});
|
||||||
|
} else if (isGrouped) {
|
||||||
|
// 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제
|
||||||
|
idsToClone = components
|
||||||
|
.filter((c) => c.groupId === component.groupId && !c.locked)
|
||||||
|
.map((c) => c.id);
|
||||||
|
} else {
|
||||||
|
// 단일 컴포넌트
|
||||||
|
idsToClone = [component.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idsToClone.length > 0) {
|
||||||
|
// 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용)
|
||||||
|
const positionsMap = new Map<string, { x: number; y: number }>();
|
||||||
|
idsToClone.forEach((id) => {
|
||||||
|
const comp = components.find((c) => c.id === id);
|
||||||
|
if (comp) {
|
||||||
|
positionsMap.set(id, { x: comp.x, y: comp.y });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 복제 생성 (오프셋 없이 원래 위치에)
|
||||||
|
const newIds = duplicateAtPosition(idsToClone, 0, 0);
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
// 복제된 컴포넌트 ID와 원본 위치 매핑
|
||||||
|
// newIds[i]는 idsToClone[i]에서 복제됨
|
||||||
|
const dupPositionsMap = new Map<string, { x: number; y: number }>();
|
||||||
|
newIds.forEach((newId, index) => {
|
||||||
|
const originalId = idsToClone[index];
|
||||||
|
const originalPos = positionsMap.get(originalId);
|
||||||
|
if (originalPos) {
|
||||||
|
dupPositionsMap.set(newId, originalPos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
originalPositionsRef.current = dupPositionsMap;
|
||||||
|
|
||||||
|
// 복제된 컴포넌트들을 선택하고 드래그 시작
|
||||||
|
duplicatedIdsRef.current = newIds;
|
||||||
|
setIsAltDuplicating(true);
|
||||||
|
|
||||||
|
// 복제된 컴포넌트들 선택
|
||||||
|
if (newIds.length === 1) {
|
||||||
|
selectComponent(newIds[0], false);
|
||||||
|
} else {
|
||||||
|
selectMultipleComponents(newIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
|
@ -284,14 +524,58 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const deltaX = snappedX - component.x;
|
const deltaX = snappedX - component.x;
|
||||||
const deltaY = snappedY - component.y;
|
const deltaY = snappedY - component.y;
|
||||||
|
|
||||||
|
// Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동
|
||||||
|
if (isAltDuplicating && duplicatedIdsRef.current.length > 0) {
|
||||||
|
// 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정)
|
||||||
|
duplicatedIdsRef.current.forEach((dupId) => {
|
||||||
|
const dupComp = components.find((c) => c.id === dupId);
|
||||||
|
const originalPos = originalPositionsRef.current.get(dupId);
|
||||||
|
|
||||||
|
if (dupComp && originalPos) {
|
||||||
|
// 각 복제본의 원본 위치에서 delta만큼 이동
|
||||||
|
const targetX = originalPos.x + deltaX;
|
||||||
|
const targetY = originalPos.y + deltaY;
|
||||||
|
|
||||||
|
// 경계 체크
|
||||||
|
const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width;
|
||||||
|
const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height;
|
||||||
|
|
||||||
|
updateComponent(dupId, {
|
||||||
|
x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX),
|
||||||
|
y: Math.min(Math.max(marginTopPx, targetY), dupMaxY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // 원본 컴포넌트는 이동하지 않음
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 컴포넌트 이동
|
// 현재 컴포넌트 이동
|
||||||
updateComponent(component.id, {
|
updateComponent(component.id, {
|
||||||
x: snappedX,
|
x: snappedX,
|
||||||
y: snappedY,
|
y: snappedY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동
|
||||||
|
if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) {
|
||||||
|
components.forEach((c) => {
|
||||||
|
// 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외
|
||||||
|
if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) {
|
||||||
|
const newMultiX = c.x + deltaX;
|
||||||
|
const newMultiY = c.y + deltaY;
|
||||||
|
|
||||||
|
// 경계 체크
|
||||||
|
const multiMaxX = canvasWidthPx - marginRightPx - c.width;
|
||||||
|
const multiMaxY = canvasHeightPx - marginBottomPx - c.height;
|
||||||
|
|
||||||
|
updateComponent(c.id, {
|
||||||
|
x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX),
|
||||||
|
y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
|
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
|
||||||
if (isGrouped) {
|
else if (isGrouped) {
|
||||||
components.forEach((c) => {
|
components.forEach((c) => {
|
||||||
if (c.groupId === component.groupId && c.id !== component.id) {
|
if (c.groupId === component.groupId && c.id !== component.id) {
|
||||||
const newGroupX = c.x + deltaX;
|
const newGroupX = c.x + deltaX;
|
||||||
|
|
@ -369,6 +653,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
|
// Alt 복제 상태 초기화
|
||||||
|
setIsAltDuplicating(false);
|
||||||
|
duplicatedIdsRef.current = [];
|
||||||
|
originalPositionsRef.current = new Map();
|
||||||
// 가이드라인 초기화
|
// 가이드라인 초기화
|
||||||
clearAlignmentGuides();
|
clearAlignmentGuides();
|
||||||
};
|
};
|
||||||
|
|
@ -383,6 +671,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
}, [
|
}, [
|
||||||
isDragging,
|
isDragging,
|
||||||
isResizing,
|
isResizing,
|
||||||
|
isAltDuplicating,
|
||||||
dragStart.x,
|
dragStart.x,
|
||||||
dragStart.y,
|
dragStart.y,
|
||||||
resizeStart.x,
|
resizeStart.x,
|
||||||
|
|
@ -405,36 +694,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 표시할 값 결정
|
|
||||||
const getDisplayValue = (): string => {
|
|
||||||
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
|
|
||||||
if (component.queryId && component.fieldName) {
|
|
||||||
const queryResult = getQueryResult(component.queryId);
|
|
||||||
|
|
||||||
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
|
|
||||||
if (queryResult && queryResult.rows.length > 0) {
|
|
||||||
const firstRow = queryResult.rows[0];
|
|
||||||
const value = firstRow[component.fieldName];
|
|
||||||
|
|
||||||
// 값이 있으면 문자열로 변환하여 반환
|
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행 결과가 없거나 값이 없으면 필드명 표시
|
|
||||||
return `{${component.fieldName}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본값이 있으면 기본값 표시
|
|
||||||
if (component.defaultValue) {
|
|
||||||
return component.defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 둘 다 없으면 타입에 따라 기본 텍스트
|
|
||||||
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 타입별 렌더링
|
// 컴포넌트 타입별 렌더링
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const displayValue = getDisplayValue();
|
const displayValue = getDisplayValue();
|
||||||
|
|
@ -443,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
switch (component.type) {
|
switch (component.type) {
|
||||||
case "text":
|
case "text":
|
||||||
case "label":
|
case "label":
|
||||||
|
// 인라인 편집 모드
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleEditSave}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
className="h-full w-full resize-none border-none bg-transparent p-0 outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
style={{
|
||||||
|
fontSize: `${component.fontSize}px`,
|
||||||
|
color: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
textAlign: component.textAlign as "left" | "center" | "right",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
|
@ -1182,6 +1462,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
: "1px solid #e5e7eb",
|
: "1px solid #e5e7eb",
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
||||||
|
|
@ -201,6 +201,7 @@ export function ReportDesignerCanvas() {
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
margins,
|
margins,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
selectMultipleComponents,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
selectedComponentIds,
|
selectedComponentIds,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
|
|
@ -210,12 +211,32 @@ export function ReportDesignerCanvas() {
|
||||||
alignmentGuides,
|
alignmentGuides,
|
||||||
copyComponents,
|
copyComponents,
|
||||||
pasteComponents,
|
pasteComponents,
|
||||||
|
duplicateComponents,
|
||||||
|
copyStyles,
|
||||||
|
pasteStyles,
|
||||||
|
fitSelectedToContent,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
showRuler,
|
showRuler,
|
||||||
layoutConfig,
|
layoutConfig,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
|
|
||||||
|
// 드래그 영역 선택 (Marquee Selection) 상태
|
||||||
|
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||||
|
const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
|
||||||
|
const [marqueeEnd, setMarqueeEnd] = useState({ x: 0, y: 0 });
|
||||||
|
// 클로저 문제 해결을 위한 refs (동기적으로 업데이트)
|
||||||
|
const marqueeStartRef = useRef({ x: 0, y: 0 });
|
||||||
|
const marqueeEndRef = useRef({ x: 0, y: 0 });
|
||||||
|
const componentsRef = useRef(components);
|
||||||
|
const selectMultipleRef = useRef(selectMultipleComponents);
|
||||||
|
// 마퀴 선택 직후 click 이벤트 무시를 위한 플래그
|
||||||
|
const justFinishedMarqueeRef = useRef(false);
|
||||||
|
|
||||||
|
// refs 동기적 업데이트 (useEffect 대신 직접 할당)
|
||||||
|
componentsRef.current = components;
|
||||||
|
selectMultipleRef.current = selectMultipleComponents;
|
||||||
|
|
||||||
const [{ isOver }, drop] = useDrop(() => ({
|
const [{ isOver }, drop] = useDrop(() => ({
|
||||||
accept: "component",
|
accept: "component",
|
||||||
drop: (item: { componentType: string }, monitor) => {
|
drop: (item: { componentType: string }, monitor) => {
|
||||||
|
|
@ -420,12 +441,127 @@ export function ReportDesignerCanvas() {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
|
||||||
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.target === e.currentTarget) {
|
// 마퀴 선택 직후의 click 이벤트는 무시
|
||||||
|
if (justFinishedMarqueeRef.current) {
|
||||||
|
justFinishedMarqueeRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target === e.currentTarget && !isMarqueeSelecting) {
|
||||||
selectComponent(null);
|
selectComponent(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 영역 선택 시작
|
||||||
|
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외)
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// state와 ref 모두 설정
|
||||||
|
setIsMarqueeSelecting(true);
|
||||||
|
setMarqueeStart({ x, y });
|
||||||
|
setMarqueeEnd({ x, y });
|
||||||
|
marqueeStartRef.current = { x, y };
|
||||||
|
marqueeEndRef.current = { x, y };
|
||||||
|
|
||||||
|
// Ctrl/Cmd 키가 눌리지 않았으면 기존 선택 해제
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
selectComponent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 영역 선택 중
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMarqueeSelecting) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(e.clientX - rect.left, canvasWidth * MM_TO_PX));
|
||||||
|
const y = Math.max(0, Math.min(e.clientY - rect.top, canvasHeight * MM_TO_PX));
|
||||||
|
|
||||||
|
// state와 ref 둘 다 업데이트
|
||||||
|
setMarqueeEnd({ x, y });
|
||||||
|
marqueeEndRef.current = { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
// ref에서 최신 값 가져오기 (클로저 문제 해결)
|
||||||
|
const currentStart = marqueeStartRef.current;
|
||||||
|
const currentEnd = marqueeEndRef.current;
|
||||||
|
const currentComponents = componentsRef.current;
|
||||||
|
const currentSelectMultiple = selectMultipleRef.current;
|
||||||
|
|
||||||
|
// 선택 영역 계산
|
||||||
|
const selectionRect = {
|
||||||
|
left: Math.min(currentStart.x, currentEnd.x),
|
||||||
|
top: Math.min(currentStart.y, currentEnd.y),
|
||||||
|
right: Math.max(currentStart.x, currentEnd.x),
|
||||||
|
bottom: Math.max(currentStart.y, currentEnd.y),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
|
||||||
|
const dragDistance = Math.sqrt(
|
||||||
|
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dragDistance > 5) {
|
||||||
|
// 선택 영역과 교차하는 컴포넌트 찾기
|
||||||
|
const intersectingComponents = currentComponents.filter((comp) => {
|
||||||
|
const compRect = {
|
||||||
|
left: comp.x,
|
||||||
|
top: comp.y,
|
||||||
|
right: comp.x + comp.width,
|
||||||
|
bottom: comp.y + comp.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 두 사각형이 교차하는지 확인
|
||||||
|
return !(
|
||||||
|
compRect.right < selectionRect.left ||
|
||||||
|
compRect.left > selectionRect.right ||
|
||||||
|
compRect.bottom < selectionRect.top ||
|
||||||
|
compRect.top > selectionRect.bottom
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 교차하는 컴포넌트들 한번에 선택
|
||||||
|
if (intersectingComponents.length > 0) {
|
||||||
|
const ids = intersectingComponents.map((comp) => comp.id);
|
||||||
|
currentSelectMultiple(ids);
|
||||||
|
// click 이벤트가 선택을 해제하지 않도록 플래그 설정
|
||||||
|
justFinishedMarqueeRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMarqueeSelecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isMarqueeSelecting, canvasWidth, canvasHeight]);
|
||||||
|
|
||||||
|
// 선택 영역 사각형 계산
|
||||||
|
const getMarqueeRect = () => {
|
||||||
|
return {
|
||||||
|
left: Math.min(marqueeStart.x, marqueeEnd.x),
|
||||||
|
top: Math.min(marqueeStart.y, marqueeEnd.y),
|
||||||
|
width: Math.abs(marqueeEnd.x - marqueeStart.x),
|
||||||
|
height: Math.abs(marqueeEnd.y - marqueeStart.y),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
|
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -497,16 +633,46 @@ export function ReportDesignerCanvas() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+C (또는 Cmd+Shift+C): 스타일 복사 (일반 복사보다 먼저 체크)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||||
|
e.preventDefault();
|
||||||
|
copyStyles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+V (또는 Cmd+Shift+V): 스타일 붙여넣기 (일반 붙여넣기보다 먼저 체크)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "v") {
|
||||||
|
e.preventDefault();
|
||||||
|
pasteStyles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+F (또는 Cmd+Shift+F): 텍스트 크기 자동 맞춤
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "f") {
|
||||||
|
e.preventDefault();
|
||||||
|
fitSelectedToContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+C (또는 Cmd+C): 복사
|
// Ctrl+C (또는 Cmd+C): 복사
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
copyComponents();
|
copyComponents();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V (또는 Cmd+V): 붙여넣기
|
// Ctrl+V (또는 Cmd+V): 붙여넣기
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
pasteComponents();
|
pasteComponents();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+D (또는 Cmd+D): 복제
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
|
||||||
|
e.preventDefault();
|
||||||
|
duplicateComponents();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
|
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
|
||||||
|
|
@ -538,6 +704,10 @@ export function ReportDesignerCanvas() {
|
||||||
removeComponent,
|
removeComponent,
|
||||||
copyComponents,
|
copyComponents,
|
||||||
pasteComponents,
|
pasteComponents,
|
||||||
|
duplicateComponents,
|
||||||
|
copyStyles,
|
||||||
|
pasteStyles,
|
||||||
|
fitSelectedToContent,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
]);
|
]);
|
||||||
|
|
@ -592,8 +762,10 @@ export function ReportDesignerCanvas() {
|
||||||
`
|
`
|
||||||
: undefined,
|
: undefined,
|
||||||
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
||||||
|
cursor: isMarqueeSelecting ? "crosshair" : "default",
|
||||||
}}
|
}}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
|
onMouseDown={handleCanvasMouseDown}
|
||||||
>
|
>
|
||||||
{/* 페이지 여백 가이드 */}
|
{/* 페이지 여백 가이드 */}
|
||||||
{currentPage && (
|
{currentPage && (
|
||||||
|
|
@ -648,6 +820,20 @@ export function ReportDesignerCanvas() {
|
||||||
<CanvasComponent key={component.id} component={component} />
|
<CanvasComponent key={component.id} component={component} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 드래그 영역 선택 사각형 */}
|
||||||
|
{isMarqueeSelecting && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
|
||||||
|
style={{
|
||||||
|
left: `${getMarqueeRect().left}px`,
|
||||||
|
top: `${getMarqueeRect().top}px`,
|
||||||
|
width: `${getMarqueeRect().width}px`,
|
||||||
|
height: `${getMarqueeRect().height}px`,
|
||||||
|
zIndex: 10000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 빈 캔버스 안내 */}
|
{/* 빈 캔버스 안내 */}
|
||||||
{components.length === 0 && (
|
{components.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export function ReportDesignerToolbar() {
|
||||||
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
|
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
|
||||||
await saveLayoutWithMenus(selectedMenuObjids);
|
await saveLayoutWithMenus(selectedMenuObjids);
|
||||||
if (pendingSaveAndClose) {
|
if (pendingSaveAndClose) {
|
||||||
router.push("/admin/report");
|
router.push("/admin/screenMng/reportList");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -151,7 +151,7 @@ export function ReportDesignerToolbar() {
|
||||||
|
|
||||||
const handleBackConfirm = () => {
|
const handleBackConfirm = () => {
|
||||||
setShowBackConfirm(false);
|
setShowBackConfirm(false);
|
||||||
router.push("/admin/report");
|
router.push("/admin/screenMng/reportList");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAsTemplate = async (data: {
|
const handleSaveAsTemplate = async (data: {
|
||||||
|
|
|
||||||
|
|
@ -996,14 +996,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
screenId: modalState.screenId, // 화면 ID 추가
|
screenId: modalState.screenId, // 화면 ID 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔍 디버깅: enrichedFormData 확인
|
|
||||||
console.log("🔑 [EditModal] enrichedFormData 생성:", {
|
|
||||||
"screenData.screenInfo": screenData.screenInfo,
|
|
||||||
"screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
|
|
||||||
"enrichedFormData.tableName": enrichedFormData.tableName,
|
|
||||||
"enrichedFormData.id": enrichedFormData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InteractiveScreenViewerDynamic
|
<InteractiveScreenViewerDynamic
|
||||||
key={component.id}
|
key={component.id}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
||||||
import type { MenuItem } from "@/lib/api/menu";
|
import type { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
|
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth"; // user 정보 가져오기
|
||||||
|
|
||||||
interface MenuContextType {
|
interface MenuContextType {
|
||||||
adminMenus: MenuItem[];
|
adminMenus: MenuItem[];
|
||||||
|
|
@ -18,6 +19,7 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
||||||
const [adminMenus, setAdminMenus] = useState<MenuItem[]>([]);
|
const [adminMenus, setAdminMenus] = useState<MenuItem[]>([]);
|
||||||
const [userMenus, setUserMenus] = useState<MenuItem[]>([]);
|
const [userMenus, setUserMenus] = useState<MenuItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { user } = useAuth(); // user 정보 가져오기
|
||||||
|
|
||||||
const convertMenuData = (data: any[]): MenuItem[] => {
|
const convertMenuData = (data: any[]): MenuItem[] => {
|
||||||
return data.map((item) => ({
|
return data.map((item) => ({
|
||||||
|
|
@ -96,8 +98,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// user.companyCode가 변경되면 메뉴 다시 로드
|
||||||
|
// console.log("🔄 MenuContext: user.companyCode 변경 감지, 메뉴 재로드", user?.companyCode);
|
||||||
loadMenus();
|
loadMenus();
|
||||||
}, []); // 초기 로드만
|
}, [user?.companyCode]); // companyCode 변경 시 재로드
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuContext.Provider value={{ adminMenus, userMenus, loading, refreshMenus }}>{children}</MenuContext.Provider>
|
<MenuContext.Provider value={{ adminMenus, userMenus, loading, refreshMenus }}>{children}</MenuContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ interface ReportDesignerContextType {
|
||||||
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
||||||
removeComponent: (id: string) => void;
|
removeComponent: (id: string) => void;
|
||||||
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
|
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
|
||||||
|
selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택
|
||||||
|
|
||||||
// 레이아웃 관리
|
// 레이아웃 관리
|
||||||
updateLayout: (updates: Partial<ReportLayout>) => void;
|
updateLayout: (updates: Partial<ReportLayout>) => void;
|
||||||
|
|
@ -100,6 +101,11 @@ interface ReportDesignerContextType {
|
||||||
// 복사/붙여넣기
|
// 복사/붙여넣기
|
||||||
copyComponents: () => void;
|
copyComponents: () => void;
|
||||||
pasteComponents: () => void;
|
pasteComponents: () => void;
|
||||||
|
duplicateComponents: () => void; // Ctrl+D 즉시 복제
|
||||||
|
copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
|
||||||
|
pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
|
||||||
|
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
|
||||||
|
fitSelectedToContent: () => void; // Ctrl+Shift+F 텍스트 크기 자동 맞춤
|
||||||
|
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
|
|
@ -267,6 +273,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 클립보드 (복사/붙여넣기)
|
// 클립보드 (복사/붙여넣기)
|
||||||
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
||||||
|
|
||||||
|
// 스타일 클립보드 (스타일만 복사/붙여넣기)
|
||||||
|
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | null>(null);
|
||||||
|
|
||||||
// Undo/Redo 히스토리
|
// Undo/Redo 히스토리
|
||||||
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
|
@ -284,7 +293,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 복사 (Ctrl+C)
|
// 복사 (Ctrl+C)
|
||||||
const copyComponents = useCallback(() => {
|
const copyComponents = useCallback(() => {
|
||||||
if (selectedComponentIds.length > 0) {
|
if (selectedComponentIds.length > 0) {
|
||||||
const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id));
|
// 잠긴 컴포넌트는 복사에서 제외
|
||||||
|
const componentsToCopy = components.filter(
|
||||||
|
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
|
||||||
|
);
|
||||||
|
if (componentsToCopy.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "복사 불가",
|
||||||
|
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setClipboard(componentsToCopy);
|
setClipboard(componentsToCopy);
|
||||||
toast({
|
toast({
|
||||||
title: "복사 완료",
|
title: "복사 완료",
|
||||||
|
|
@ -293,6 +313,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
} else if (selectedComponentId) {
|
} else if (selectedComponentId) {
|
||||||
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
|
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
|
||||||
if (componentToCopy) {
|
if (componentToCopy) {
|
||||||
|
// 잠긴 컴포넌트는 복사 불가
|
||||||
|
if (componentToCopy.locked) {
|
||||||
|
toast({
|
||||||
|
title: "복사 불가",
|
||||||
|
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setClipboard([componentToCopy]);
|
setClipboard([componentToCopy]);
|
||||||
toast({
|
toast({
|
||||||
title: "복사 완료",
|
title: "복사 완료",
|
||||||
|
|
@ -332,6 +361,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
});
|
});
|
||||||
}, [clipboard, components.length, toast]);
|
}, [clipboard, components.length, toast]);
|
||||||
|
|
||||||
|
// 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제
|
||||||
|
const duplicateComponents = useCallback(() => {
|
||||||
|
// 복제할 컴포넌트 결정
|
||||||
|
let componentsToDuplicate: ComponentConfig[] = [];
|
||||||
|
|
||||||
|
if (selectedComponentIds.length > 0) {
|
||||||
|
componentsToDuplicate = components.filter(
|
||||||
|
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
|
||||||
|
);
|
||||||
|
} else if (selectedComponentId) {
|
||||||
|
const comp = components.find((c) => c.id === selectedComponentId);
|
||||||
|
if (comp && !comp.locked) {
|
||||||
|
componentsToDuplicate = [comp];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentsToDuplicate.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "복제 불가",
|
||||||
|
description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComponents = componentsToDuplicate.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
x: comp.x + 20,
|
||||||
|
y: comp.y + 20,
|
||||||
|
zIndex: components.length,
|
||||||
|
locked: false, // 복제된 컴포넌트는 잠금 해제
|
||||||
|
}));
|
||||||
|
|
||||||
|
setComponents((prev) => [...prev, ...newComponents]);
|
||||||
|
|
||||||
|
// 복제된 컴포넌트 선택
|
||||||
|
if (newComponents.length === 1) {
|
||||||
|
setSelectedComponentId(newComponents[0].id);
|
||||||
|
setSelectedComponentIds([newComponents[0].id]);
|
||||||
|
} else {
|
||||||
|
setSelectedComponentIds(newComponents.map((c) => c.id));
|
||||||
|
setSelectedComponentId(newComponents[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "복제 완료",
|
||||||
|
description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// 스타일 복사 (Ctrl+Shift+C)
|
||||||
|
const copyStyles = useCallback(() => {
|
||||||
|
// 단일 컴포넌트만 스타일 복사 가능
|
||||||
|
const targetId = selectedComponentId || selectedComponentIds[0];
|
||||||
|
if (!targetId) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 복사 불가",
|
||||||
|
description: "컴포넌트를 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = components.find((c) => c.id === targetId);
|
||||||
|
if (!component) return;
|
||||||
|
|
||||||
|
// 스타일 관련 속성만 추출
|
||||||
|
const styleProperties: Partial<ComponentConfig> = {
|
||||||
|
fontSize: component.fontSize,
|
||||||
|
fontColor: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
fontFamily: component.fontFamily,
|
||||||
|
textAlign: component.textAlign,
|
||||||
|
backgroundColor: component.backgroundColor,
|
||||||
|
borderWidth: component.borderWidth,
|
||||||
|
borderColor: component.borderColor,
|
||||||
|
borderStyle: component.borderStyle,
|
||||||
|
borderRadius: component.borderRadius,
|
||||||
|
boxShadow: component.boxShadow,
|
||||||
|
opacity: component.opacity,
|
||||||
|
padding: component.padding,
|
||||||
|
letterSpacing: component.letterSpacing,
|
||||||
|
lineHeight: component.lineHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
// undefined 값 제거
|
||||||
|
Object.keys(styleProperties).forEach((key) => {
|
||||||
|
if (styleProperties[key as keyof typeof styleProperties] === undefined) {
|
||||||
|
delete styleProperties[key as keyof typeof styleProperties];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStyleClipboard(styleProperties);
|
||||||
|
toast({
|
||||||
|
title: "스타일 복사 완료",
|
||||||
|
description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.",
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// 스타일 붙여넣기 (Ctrl+Shift+V)
|
||||||
|
const pasteStyles = useCallback(() => {
|
||||||
|
if (!styleClipboard) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 붙여넣기 불가",
|
||||||
|
description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 컴포넌트들에 스타일 적용
|
||||||
|
const targetIds =
|
||||||
|
selectedComponentIds.length > 0
|
||||||
|
? selectedComponentIds
|
||||||
|
: selectedComponentId
|
||||||
|
? [selectedComponentId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 붙여넣기 불가",
|
||||||
|
description: "스타일을 적용할 컴포넌트를 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잠긴 컴포넌트 필터링
|
||||||
|
const applicableIds = targetIds.filter((id) => {
|
||||||
|
const comp = components.find((c) => c.id === id);
|
||||||
|
return comp && !comp.locked;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (applicableIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 붙여넣기 불가",
|
||||||
|
description: "잠긴 컴포넌트에는 스타일을 적용할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setComponents((prev) =>
|
||||||
|
prev.map((comp) => {
|
||||||
|
if (applicableIds.includes(comp.id)) {
|
||||||
|
return { ...comp, ...styleClipboard };
|
||||||
|
}
|
||||||
|
return comp;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "스타일 적용 완료",
|
||||||
|
description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제
|
||||||
|
const duplicateAtPosition = useCallback(
|
||||||
|
(componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => {
|
||||||
|
const componentsToDuplicate = components.filter(
|
||||||
|
(comp) => componentIds.includes(comp.id) && !comp.locked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (componentsToDuplicate.length === 0) return [];
|
||||||
|
|
||||||
|
const newComponents = componentsToDuplicate.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
x: comp.x + offsetX,
|
||||||
|
y: comp.y + offsetY,
|
||||||
|
zIndex: components.length,
|
||||||
|
locked: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setComponents((prev) => [...prev, ...newComponents]);
|
||||||
|
|
||||||
|
return newComponents.map((c) => c.id);
|
||||||
|
},
|
||||||
|
[components]
|
||||||
|
);
|
||||||
|
|
||||||
// 히스토리에 현재 상태 저장
|
// 히스토리에 현재 상태 저장
|
||||||
const saveToHistory = useCallback(
|
const saveToHistory = useCallback(
|
||||||
(newComponents: ComponentConfig[]) => {
|
(newComponents: ComponentConfig[]) => {
|
||||||
|
|
@ -1292,6 +1504,114 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
[currentPageId],
|
[currentPageId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 텍스트 컴포넌트 크기 자동 맞춤 (Ctrl+Shift+F)
|
||||||
|
const fitSelectedToContent = useCallback(() => {
|
||||||
|
const MM_TO_PX = 4; // 고정 스케일 팩터
|
||||||
|
|
||||||
|
// 선택된 컴포넌트 ID 결정
|
||||||
|
const targetIds =
|
||||||
|
selectedComponentIds.length > 0
|
||||||
|
? selectedComponentIds
|
||||||
|
: selectedComponentId
|
||||||
|
? [selectedComponentId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (targetIds.length === 0) return;
|
||||||
|
|
||||||
|
// 텍스트/레이블 컴포넌트만 필터링
|
||||||
|
const textComponents = components.filter(
|
||||||
|
(c) =>
|
||||||
|
targetIds.includes(c.id) &&
|
||||||
|
(c.type === "text" || c.type === "label") &&
|
||||||
|
!c.locked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textComponents.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "크기 조정 불가",
|
||||||
|
description: "선택된 텍스트 컴포넌트가 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 페이지 설정 가져오기
|
||||||
|
const page = currentPage;
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const canvasWidthPx = page.width * MM_TO_PX;
|
||||||
|
const canvasHeightPx = page.height * MM_TO_PX;
|
||||||
|
const marginRightPx = (page.margins?.right || 10) * MM_TO_PX;
|
||||||
|
const marginBottomPx = (page.margins?.bottom || 10) * MM_TO_PX;
|
||||||
|
|
||||||
|
// 각 텍스트 컴포넌트 크기 조정
|
||||||
|
textComponents.forEach((comp) => {
|
||||||
|
const displayValue = comp.defaultValue || (comp.type === "text" ? "텍스트 입력" : "레이블 텍스트");
|
||||||
|
const fontSize = comp.fontSize || 14;
|
||||||
|
|
||||||
|
// 최대 크기 (여백 고려)
|
||||||
|
const maxWidth = canvasWidthPx - marginRightPx - comp.x;
|
||||||
|
const maxHeight = canvasHeightPx - marginBottomPx - comp.y;
|
||||||
|
|
||||||
|
// 줄바꿈으로 분리하여 각 줄의 너비 측정
|
||||||
|
const lines = displayValue.split("\n");
|
||||||
|
let maxLineWidth = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
const measureEl = document.createElement("span");
|
||||||
|
measureEl.style.position = "absolute";
|
||||||
|
measureEl.style.visibility = "hidden";
|
||||||
|
measureEl.style.whiteSpace = "nowrap";
|
||||||
|
measureEl.style.fontSize = `${fontSize}px`;
|
||||||
|
measureEl.style.fontWeight = comp.fontWeight || "normal";
|
||||||
|
measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
|
||||||
|
measureEl.textContent = line || " ";
|
||||||
|
document.body.appendChild(measureEl);
|
||||||
|
|
||||||
|
const lineWidth = measureEl.getBoundingClientRect().width;
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||||||
|
document.body.removeChild(measureEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 패딩 및 높이 계산
|
||||||
|
const horizontalPadding = 24;
|
||||||
|
const verticalPadding = 20;
|
||||||
|
const lineHeight = fontSize * 1.5;
|
||||||
|
const totalHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
|
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
|
||||||
|
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
|
||||||
|
|
||||||
|
const newWidth = Math.max(50, finalWidth);
|
||||||
|
const newHeight = Math.max(30, finalHeight);
|
||||||
|
|
||||||
|
// 크기 업데이트 - setLayoutConfig 직접 사용
|
||||||
|
setLayoutConfig((prev) => ({
|
||||||
|
pages: prev.pages.map((p) =>
|
||||||
|
p.page_id === currentPageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
components: p.components.map((c) =>
|
||||||
|
c.id === comp.id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
width: snapToGrid ? Math.round(newWidth / gridSize) * gridSize : newWidth,
|
||||||
|
height: snapToGrid ? Math.round(newHeight / gridSize) * gridSize : newHeight,
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "크기 조정 완료",
|
||||||
|
description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, currentPage, currentPageId, snapToGrid, gridSize, toast]);
|
||||||
|
|
||||||
// 컴포넌트 삭제 (현재 페이지에서)
|
// 컴포넌트 삭제 (현재 페이지에서)
|
||||||
const removeComponent = useCallback(
|
const removeComponent = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
|
|
@ -1344,6 +1664,17 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 여러 컴포넌트 한번에 선택 (마퀴 선택용)
|
||||||
|
const selectMultipleComponents = useCallback((ids: string[]) => {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setSelectedComponentIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedComponentId(ids[0]);
|
||||||
|
setSelectedComponentIds(ids);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 레이아웃 업데이트
|
// 레이아웃 업데이트
|
||||||
const updateLayout = useCallback(
|
const updateLayout = useCallback(
|
||||||
(updates: Partial<ReportLayout>) => {
|
(updates: Partial<ReportLayout>) => {
|
||||||
|
|
@ -1639,6 +1970,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
updateComponent,
|
updateComponent,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
selectMultipleComponents,
|
||||||
updateLayout,
|
updateLayout,
|
||||||
saveLayout,
|
saveLayout,
|
||||||
loadLayout,
|
loadLayout,
|
||||||
|
|
@ -1662,6 +1994,11 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 복사/붙여넣기
|
// 복사/붙여넣기
|
||||||
copyComponents,
|
copyComponents,
|
||||||
pasteComponents,
|
pasteComponents,
|
||||||
|
duplicateComponents,
|
||||||
|
copyStyles,
|
||||||
|
pasteStyles,
|
||||||
|
duplicateAtPosition,
|
||||||
|
fitSelectedToContent,
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,61 @@ export const useAuth = () => {
|
||||||
[apiCall, refreshUserData],
|
[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,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
switchCompany, // 🆕 회사 전환 함수
|
||||||
checkMenuAuth,
|
checkMenuAuth,
|
||||||
refreshUserData,
|
refreshUserData,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,9 @@ export const menuApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
|
// 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시, 회사별 필터링)
|
||||||
getUserMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,41 @@ export interface NodeExecutionSummary {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
error?: string;
|
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 호출 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -711,8 +711,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달
|
groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달
|
||||||
_groupedData: props.groupedData, // 하위 호환성 유지
|
_groupedData: props.groupedData, // 하위 호환성 유지
|
||||||
// 🆕 UniversalFormModal용 initialData 전달
|
// 🆕 UniversalFormModal용 initialData 전달
|
||||||
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
// originalData가 비어있지 않으면 originalData 사용, 아니면 formData 사용
|
||||||
_initialData: originalData || formData,
|
// 생성 모드에서는 originalData가 빈 객체이므로 formData를 사용해야 함
|
||||||
|
_initialData: (originalData && Object.keys(originalData).length > 0) ? originalData : formData,
|
||||||
_originalData: originalData,
|
_originalData: originalData,
|
||||||
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
||||||
parentTabId: props.parentTabId,
|
parentTabId: props.parentTabId,
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,145 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return false;
|
return false;
|
||||||
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
|
}, [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 [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<{
|
const [pendingAction, setPendingAction] = useState<{
|
||||||
|
|
@ -832,7 +971,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
|
// 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음
|
||||||
|
// (다른 화면에서 선택한 데이터가 남아있을 수 있으므로)
|
||||||
|
const shouldFetchFromModalDataStore =
|
||||||
|
processedConfig.action.type !== "modal" &&
|
||||||
|
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
|
||||||
|
effectiveTableName;
|
||||||
|
|
||||||
|
if (shouldFetchFromModalDataStore) {
|
||||||
try {
|
try {
|
||||||
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
||||||
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||||
|
|
@ -860,12 +1006,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단
|
// 🔧 모달 액션 시 선택 데이터 경고 제거
|
||||||
// (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지)
|
// 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나,
|
||||||
if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) {
|
// 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함.
|
||||||
toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요.");
|
// 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨.
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수정(edit) 액션 검증
|
// 수정(edit) 액션 검증
|
||||||
if (processedConfig.action.type === "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 = {
|
const buttonElementStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: "40px",
|
minHeight: "40px",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
background: finalDisabled ? "#e5e7eb" : buttonColor,
|
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
|
||||||
color: finalDisabled ? "#9ca3af" : "white",
|
color: finalDisabled ? "#9ca3af" : "white",
|
||||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||||
|
|
@ -1114,10 +1267,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
lineHeight: "1.25",
|
||||||
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외)
|
||||||
...(component.style
|
...userStyle,
|
||||||
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
||||||
|
|
|
||||||
|
|
@ -1636,7 +1636,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label} ({col.field})
|
{col.label} ({col.field})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1900,7 +1900,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="현재 행 필드" />
|
<SelectValue placeholder="현재 행 필드" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -2056,7 +2056,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label} ({col.field})
|
{col.label} ({col.field})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -2303,7 +2303,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="현재 행 필드" />
|
<SelectValue placeholder="현재 행 필드" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
@ -84,6 +84,9 @@ export function RepeaterTable({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
equalizeWidthsTrigger,
|
equalizeWidthsTrigger,
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
|
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
|
||||||
|
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
|
||||||
|
|
||||||
// 컨테이너 ref - 실제 너비 측정용
|
// 컨테이너 ref - 실제 너비 측정용
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -145,7 +148,7 @@ export function RepeaterTable({
|
||||||
// 컬럼 너비 상태 관리
|
// 컬럼 너비 상태 관리
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
columns.filter((col) => !col.hidden).forEach((col) => {
|
||||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||||
});
|
});
|
||||||
return widths;
|
return widths;
|
||||||
|
|
@ -154,11 +157,11 @@ export function RepeaterTable({
|
||||||
// 기본 너비 저장 (리셋용)
|
// 기본 너비 저장 (리셋용)
|
||||||
const defaultWidths = React.useMemo(() => {
|
const defaultWidths = React.useMemo(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||||
});
|
});
|
||||||
return widths;
|
return widths;
|
||||||
}, [columns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 리사이즈 상태
|
// 리사이즈 상태
|
||||||
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
||||||
|
|
@ -206,7 +209,7 @@ export function RepeaterTable({
|
||||||
// 해당 컬럼의 가장 긴 글자 너비 계산
|
// 해당 컬럼의 가장 긴 글자 너비 계산
|
||||||
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
|
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
|
||||||
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
|
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
|
||||||
const column = columns.find((col) => col.field === field);
|
const column = visibleColumns.find((col) => col.field === field);
|
||||||
if (!column) return equalWidth;
|
if (!column) return equalWidth;
|
||||||
|
|
||||||
// 날짜 필드는 110px (yyyy-MM-dd)
|
// 날짜 필드는 110px (yyyy-MM-dd)
|
||||||
|
|
@ -257,7 +260,7 @@ export function RepeaterTable({
|
||||||
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
|
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
|
||||||
const handleDoubleClick = (field: string) => {
|
const handleDoubleClick = (field: string) => {
|
||||||
const availableWidth = getAvailableWidth();
|
const availableWidth = getAvailableWidth();
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
||||||
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
||||||
setColumnWidths((prev) => ({
|
setColumnWidths((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -268,10 +271,10 @@ export function RepeaterTable({
|
||||||
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
|
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
|
||||||
const applyEqualizeWidths = () => {
|
const applyEqualizeWidths = () => {
|
||||||
const availableWidth = getAvailableWidth();
|
const availableWidth = getAvailableWidth();
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
||||||
|
|
||||||
const newWidths: Record<string, number> = {};
|
const newWidths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
newWidths[col.field] = equalWidth;
|
newWidths[col.field] = equalWidth;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -280,15 +283,15 @@ export function RepeaterTable({
|
||||||
|
|
||||||
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
||||||
const applyAutoFitWidths = () => {
|
const applyAutoFitWidths = () => {
|
||||||
if (columns.length === 0) return;
|
if (visibleColumns.length === 0) return;
|
||||||
|
|
||||||
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
|
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
|
||||||
const availableWidth = getAvailableWidth();
|
const availableWidth = getAvailableWidth();
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
||||||
|
|
||||||
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
|
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
|
||||||
const newWidths: Record<string, number> = {};
|
const newWidths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
|
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -298,8 +301,8 @@ export function RepeaterTable({
|
||||||
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
|
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
|
||||||
if (totalContentWidth < availableWidth) {
|
if (totalContentWidth < availableWidth) {
|
||||||
const extraSpace = availableWidth - totalContentWidth;
|
const extraSpace = availableWidth - totalContentWidth;
|
||||||
const extraPerColumn = Math.floor(extraSpace / columns.length);
|
const extraPerColumn = Math.floor(extraSpace / visibleColumns.length);
|
||||||
columns.forEach((col) => {
|
visibleColumns.forEach((col) => {
|
||||||
newWidths[col.field] += extraPerColumn;
|
newWidths[col.field] += extraPerColumn;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +314,7 @@ export function RepeaterTable({
|
||||||
// 초기 마운트 시 균등 분배 적용
|
// 초기 마운트 시 균등 분배 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initializedRef.current) return;
|
if (initializedRef.current) return;
|
||||||
if (!containerRef.current || columns.length === 0) return;
|
if (!containerRef.current || visibleColumns.length === 0) return;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
applyEqualizeWidths();
|
applyEqualizeWidths();
|
||||||
|
|
@ -319,7 +322,7 @@ export function RepeaterTable({
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [columns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
// 트리거 감지: 1=균등분배, 2=자동맞춤
|
// 트리거 감지: 1=균등분배, 2=자동맞춤
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +360,7 @@ export function RepeaterTable({
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [resizing, columns, data]);
|
}, [resizing, visibleColumns, data]);
|
||||||
|
|
||||||
// 데이터 변경 감지 (필요시 활성화)
|
// 데이터 변경 감지 (필요시 활성화)
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|
@ -481,7 +484,7 @@ export function RepeaterTable({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{column.selectOptions?.map((option) => (
|
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -531,7 +534,7 @@ export function RepeaterTable({
|
||||||
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
|
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
{columns.map((col) => {
|
{visibleColumns.map((col) => {
|
||||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||||
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||||
const activeOption = hasDynamicSource
|
const activeOption = hasDynamicSource
|
||||||
|
|
@ -631,7 +634,7 @@ export function RepeaterTable({
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length + 2}
|
colSpan={visibleColumns.length + 2}
|
||||||
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
추가된 항목이 없습니다
|
추가된 항목이 없습니다
|
||||||
|
|
@ -678,7 +681,7 @@ export function RepeaterTable({
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{/* 데이터 컬럼들 */}
|
{/* 데이터 컬럼들 */}
|
||||||
{columns.map((col) => (
|
{visibleColumns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.field}
|
key={col.field}
|
||||||
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
|
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface RepeaterColumnConfig {
|
||||||
calculated?: boolean; // 계산 필드 여부
|
calculated?: boolean; // 계산 필드 여부
|
||||||
width?: string; // 컬럼 너비
|
width?: string; // 컬럼 너비
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
|
hidden?: boolean; // 히든 필드 여부 (UI에서 숨기지만 데이터는 유지)
|
||||||
defaultValue?: string | number | boolean; // 기본값
|
defaultValue?: string | number | boolean; // 기본값
|
||||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -561,7 +561,7 @@ export function SimpleRepeaterTableComponent({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{column.selectOptions?.map((option) => (
|
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -1539,7 +1539,7 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).filter(c => c.type === "number").map((col) => (
|
{(localConfig.columns || []).filter(c => c.type === "number" && c.field && c.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="border-border flex flex-shrink-0 flex-col border-r"
|
className="border-border flex flex-shrink-0 flex-col border-r"
|
||||||
>
|
>
|
||||||
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
className="flex-shrink-0 border-b"
|
className="flex-shrink-0 border-b"
|
||||||
style={{
|
style={{
|
||||||
height: componentConfig.leftPanel?.panelHeaderHeight || 48,
|
height: componentConfig.leftPanel?.panelHeaderHeight || 48,
|
||||||
minHeight: componentConfig.leftPanel?.panelHeaderHeight || 48,
|
minHeight: componentConfig.leftPanel?.panelHeaderHeight || 48,
|
||||||
padding: '0 1rem',
|
padding: "0 1rem",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center'
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<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"
|
className="flex flex-shrink-0 flex-col"
|
||||||
>
|
>
|
||||||
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
className="flex-shrink-0 border-b"
|
className="flex-shrink-0 border-b"
|
||||||
style={{
|
style={{
|
||||||
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
||||||
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
||||||
padding: '0 1rem',
|
padding: "0 1rem",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center'
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -672,3 +672,4 @@ export const ActionButtonConfigModal: React.FC<ActionButtonConfigModalProps> = (
|
||||||
|
|
||||||
export default ActionButtonConfigModal;
|
export default ActionButtonConfigModal;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -803,3 +803,4 @@ export const ColumnConfigModal: React.FC<ColumnConfigModalProps> = ({
|
||||||
|
|
||||||
export default ColumnConfigModal;
|
export default ColumnConfigModal;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,4 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
||||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||||
|
const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right");
|
||||||
|
|
||||||
// 탭 상태 (좌측/우측 각각)
|
// 탭 상태 (좌측/우측 각각)
|
||||||
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
||||||
|
|
@ -637,9 +638,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[SplitPanelLayout2] 모달로 전달할 데이터:", initialData);
|
|
||||||
console.log("[SplitPanelLayout2] 모달 screenId:", config.rightPanel?.addModalScreenId);
|
|
||||||
|
|
||||||
// EditModal 열기 이벤트 발생
|
// EditModal 열기 이벤트 발생
|
||||||
const event = new CustomEvent("openEditModal", {
|
const event = new CustomEvent("openEditModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -665,14 +663,19 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
loadRightData,
|
loadRightData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 기본키 컬럼명 가져오기
|
// 기본키 컬럼명 가져오기 (우측 패널)
|
||||||
const getPrimaryKeyColumn = useCallback(() => {
|
const getPrimaryKeyColumn = useCallback(() => {
|
||||||
return config.rightPanel?.primaryKeyColumn || "id";
|
return config.rightPanel?.primaryKeyColumn || "id";
|
||||||
}, [config.rightPanel?.primaryKeyColumn]);
|
}, [config.rightPanel?.primaryKeyColumn]);
|
||||||
|
|
||||||
|
// 기본키 컬럼명 가져오기 (좌측 패널)
|
||||||
|
const getLeftPrimaryKeyColumn = useCallback(() => {
|
||||||
|
return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id";
|
||||||
|
}, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]);
|
||||||
|
|
||||||
// 우측 패널 수정 버튼 클릭
|
// 우측 패널 수정 버튼 클릭
|
||||||
const handleEditItem = useCallback(
|
const handleEditItem = useCallback(
|
||||||
(item: any) => {
|
async (item: any) => {
|
||||||
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
|
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
|
||||||
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
|
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
|
||||||
|
|
||||||
|
|
@ -681,6 +684,67 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 메인 테이블 데이터 조회 (우측 패널이 서브 테이블인 경우)
|
||||||
|
let editData = { ...item };
|
||||||
|
|
||||||
|
// 연결 설정이 있고, 메인 테이블이 설정되어 있으면 메인 테이블 데이터도 조회
|
||||||
|
if (config.rightPanel?.mainTableForEdit) {
|
||||||
|
const { tableName, linkColumn } = config.rightPanel.mainTableForEdit;
|
||||||
|
const linkValue = item[linkColumn?.subColumn || ""];
|
||||||
|
|
||||||
|
if (tableName && linkValue) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/data`, {
|
||||||
|
params: {
|
||||||
|
filters: JSON.stringify({ [linkColumn?.mainColumn || linkColumn?.subColumn || ""]: linkValue }),
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success && response.data?.data?.items?.[0]) {
|
||||||
|
// 메인 테이블 데이터를 editData에 병합 (서브 테이블 데이터 우선)
|
||||||
|
editData = { ...response.data.data.items[0], ...item };
|
||||||
|
console.log("[SplitPanelLayout2] 메인 테이블 데이터 병합:", editData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SplitPanelLayout2] 메인 테이블 데이터 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditModal 열기 이벤트 발생 (수정 모드)
|
||||||
|
const event = new CustomEvent("openEditModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: modalScreenId,
|
||||||
|
title: "수정",
|
||||||
|
modalSize: "lg",
|
||||||
|
editData: editData, // 병합된 데이터 전달
|
||||||
|
isCreateMode: false, // 수정 모드
|
||||||
|
onSave: () => {
|
||||||
|
if (selectedLeftItem) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
|
||||||
|
},
|
||||||
|
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 좌측 패널 수정 버튼 클릭
|
||||||
|
const handleLeftEditItem = useCallback(
|
||||||
|
(item: any) => {
|
||||||
|
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
|
||||||
|
const modalScreenId = config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
|
||||||
|
|
||||||
|
if (!modalScreenId) {
|
||||||
|
toast.error("연결된 모달 화면이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// EditModal 열기 이벤트 발생 (수정 모드)
|
// EditModal 열기 이벤트 발생 (수정 모드)
|
||||||
const event = new CustomEvent("openEditModal", {
|
const event = new CustomEvent("openEditModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -690,22 +754,29 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
editData: item, // 기존 데이터 전달
|
editData: item, // 기존 데이터 전달
|
||||||
isCreateMode: false, // 수정 모드
|
isCreateMode: false, // 수정 모드
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
if (selectedLeftItem) {
|
loadLeftData();
|
||||||
loadRightData(selectedLeftItem);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
console.log("[SplitPanelLayout2] 수정 모달 열기:", item);
|
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", item);
|
||||||
},
|
},
|
||||||
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData],
|
[config.leftPanel?.editModalScreenId, config.leftPanel?.addModalScreenId, loadLeftData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
|
// 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
|
||||||
const handleDeleteClick = useCallback((item: any) => {
|
const handleDeleteClick = useCallback((item: any) => {
|
||||||
setItemToDelete(item);
|
setItemToDelete(item);
|
||||||
setIsBulkDelete(false);
|
setIsBulkDelete(false);
|
||||||
|
setDeleteTargetPanel("right");
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 좌측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
|
||||||
|
const handleLeftDeleteClick = useCallback((item: any) => {
|
||||||
|
setItemToDelete(item);
|
||||||
|
setIsBulkDelete(false);
|
||||||
|
setDeleteTargetPanel("left");
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -716,41 +787,54 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsBulkDelete(true);
|
setIsBulkDelete(true);
|
||||||
|
setDeleteTargetPanel("right");
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}, [selectedRightItems.size]);
|
}, [selectedRightItems.size]);
|
||||||
|
|
||||||
// 실제 삭제 실행
|
// 실제 삭제 실행
|
||||||
const executeDelete = useCallback(async () => {
|
const executeDelete = useCallback(async () => {
|
||||||
if (!config.rightPanel?.tableName) {
|
// 대상 패널에 따라 테이블명과 기본키 컬럼 결정
|
||||||
|
const tableName = deleteTargetPanel === "left"
|
||||||
|
? config.leftPanel?.tableName
|
||||||
|
: config.rightPanel?.tableName;
|
||||||
|
const pkColumn = deleteTargetPanel === "left"
|
||||||
|
? getLeftPrimaryKeyColumn()
|
||||||
|
: getPrimaryKeyColumn();
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
toast.error("테이블 설정이 없습니다.");
|
toast.error("테이블 설정이 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkColumn = getPrimaryKeyColumn();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isBulkDelete) {
|
if (isBulkDelete) {
|
||||||
// 일괄 삭제
|
// 일괄 삭제 - 선택된 항목들의 데이터를 body로 전달
|
||||||
const idsToDelete = Array.from(selectedRightItems);
|
const itemsToDelete = rightData.filter((item) => selectedRightItems.has(item[pkColumn] as string | number));
|
||||||
console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete);
|
console.log("[SplitPanelLayout2] 일괄 삭제:", itemsToDelete);
|
||||||
|
|
||||||
for (const id of idsToDelete) {
|
// 백엔드 API는 body로 삭제할 데이터를 받음
|
||||||
await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`);
|
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
|
||||||
}
|
data: itemsToDelete,
|
||||||
|
});
|
||||||
|
|
||||||
toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`);
|
toast.success(`${itemsToDelete.length}개 항목이 삭제되었습니다.`);
|
||||||
setSelectedRightItems(new Set());
|
setSelectedRightItems(new Set<string | number>());
|
||||||
} else if (itemToDelete) {
|
} else if (itemToDelete) {
|
||||||
// 단일 삭제
|
// 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함)
|
||||||
const itemId = itemToDelete[pkColumn];
|
console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete);
|
||||||
console.log("[SplitPanelLayout2] 단일 삭제:", itemId);
|
|
||||||
|
|
||||||
await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`);
|
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
|
||||||
|
data: [itemToDelete],
|
||||||
|
});
|
||||||
toast.success("항목이 삭제되었습니다.");
|
toast.success("항목이 삭제되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 새로고침
|
// 데이터 새로고침
|
||||||
if (selectedLeftItem) {
|
if (deleteTargetPanel === "left") {
|
||||||
|
loadLeftData();
|
||||||
|
setSelectedLeftItem(null); // 좌측 선택 초기화
|
||||||
|
setRightData([]); // 우측 데이터도 초기화
|
||||||
|
} else if (selectedLeftItem) {
|
||||||
loadRightData(selectedLeftItem);
|
loadRightData(selectedLeftItem);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -762,13 +846,18 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
setIsBulkDelete(false);
|
setIsBulkDelete(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
deleteTargetPanel,
|
||||||
|
config.leftPanel?.tableName,
|
||||||
config.rightPanel?.tableName,
|
config.rightPanel?.tableName,
|
||||||
|
getLeftPrimaryKeyColumn,
|
||||||
getPrimaryKeyColumn,
|
getPrimaryKeyColumn,
|
||||||
isBulkDelete,
|
isBulkDelete,
|
||||||
selectedRightItems,
|
selectedRightItems,
|
||||||
itemToDelete,
|
itemToDelete,
|
||||||
selectedLeftItem,
|
selectedLeftItem,
|
||||||
|
loadLeftData,
|
||||||
loadRightData,
|
loadRightData,
|
||||||
|
rightData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 개별 체크박스 선택/해제
|
// 개별 체크박스 선택/해제
|
||||||
|
|
@ -825,7 +914,29 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const selectedId = Array.from(selectedRightItems)[0];
|
const selectedId = Array.from(selectedRightItems)[0];
|
||||||
const item = rightData.find((d) => d[pkColumn] === selectedId);
|
const item = rightData.find((d) => d[pkColumn] === selectedId);
|
||||||
if (item) {
|
if (item) {
|
||||||
handleEditItem(item);
|
// 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용
|
||||||
|
const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
|
||||||
|
|
||||||
|
if (!modalScreenId) {
|
||||||
|
toast.error("연결된 모달 화면이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new CustomEvent("openEditModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: modalScreenId,
|
||||||
|
title: btn.label || "수정",
|
||||||
|
modalSize: "lg",
|
||||||
|
editData: item,
|
||||||
|
isCreateMode: false,
|
||||||
|
onSave: () => {
|
||||||
|
if (selectedLeftItem) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
} else if (selectedRightItems.size > 1) {
|
} else if (selectedRightItems.size > 1) {
|
||||||
toast.error("수정할 항목을 1개만 선택해주세요.");
|
toast.error("수정할 항목을 1개만 선택해주세요.");
|
||||||
|
|
@ -860,6 +971,57 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 좌측 패널 액션 버튼 클릭 핸들러
|
||||||
|
const handleLeftActionButton = useCallback(
|
||||||
|
(btn: ActionButtonConfig) => {
|
||||||
|
switch (btn.action) {
|
||||||
|
case "add":
|
||||||
|
// 액션 버튼에 설정된 modalScreenId 우선 사용
|
||||||
|
const modalScreenId = btn.modalScreenId || config.leftPanel?.addModalScreenId;
|
||||||
|
|
||||||
|
if (!modalScreenId) {
|
||||||
|
toast.error("연결된 모달 화면이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditModal 열기 이벤트 발생
|
||||||
|
const event = new CustomEvent("openEditModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: modalScreenId,
|
||||||
|
title: btn.label || "추가",
|
||||||
|
modalSize: "lg",
|
||||||
|
editData: {},
|
||||||
|
isCreateMode: true,
|
||||||
|
onSave: () => {
|
||||||
|
loadLeftData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "edit":
|
||||||
|
// 좌측 패널에서 수정 (필요시 구현)
|
||||||
|
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
// 좌측 패널에서 삭제 (필요시 구현)
|
||||||
|
console.log("[SplitPanelLayout2] 좌측 삭제 액션:", btn);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "custom":
|
||||||
|
console.log("[SplitPanelLayout2] 좌측 커스텀 액션:", btn);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.leftPanel?.addModalScreenId, loadLeftData],
|
||||||
|
);
|
||||||
|
|
||||||
// 컬럼 라벨 로드
|
// 컬럼 라벨 로드
|
||||||
const loadColumnLabels = useCallback(
|
const loadColumnLabels = useCallback(
|
||||||
async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
|
async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
|
||||||
|
|
@ -1012,10 +1174,10 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
(checked: boolean) => {
|
(checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
const pkColumn = getPrimaryKeyColumn();
|
const pkColumn = getPrimaryKeyColumn();
|
||||||
const allIds = new Set(filteredRightData.map((item) => item[pkColumn]));
|
const allIds = new Set<string | number>(filteredRightData.map((item) => item[pkColumn] as string | number));
|
||||||
setSelectedRightItems(allIds);
|
setSelectedRightItems(allIds);
|
||||||
} else {
|
} else {
|
||||||
setSelectedRightItems(new Set());
|
setSelectedRightItems(new Set<string | number>());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filteredRightData, getPrimaryKeyColumn],
|
[filteredRightData, getPrimaryKeyColumn],
|
||||||
|
|
@ -1348,6 +1510,27 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 좌측 패널 수정/삭제 버튼 */}
|
||||||
|
{(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && (
|
||||||
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{config.leftPanel?.showEditButton && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleLeftEditItem(item)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{config.leftPanel?.showDeleteButton && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8"
|
||||||
|
onClick={() => handleLeftDeleteClick(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 자식 항목 */}
|
{/* 자식 항목 */}
|
||||||
|
|
@ -1360,11 +1543,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 왼쪽 패널 기본키 컬럼명 가져오기
|
|
||||||
const getLeftPrimaryKeyColumn = useCallback(() => {
|
|
||||||
return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id";
|
|
||||||
}, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]);
|
|
||||||
|
|
||||||
// 왼쪽 패널 테이블 렌더링
|
// 왼쪽 패널 테이블 렌더링
|
||||||
const renderLeftTable = () => {
|
const renderLeftTable = () => {
|
||||||
const displayColumns = config.leftPanel?.displayColumns || [];
|
const displayColumns = config.leftPanel?.displayColumns || [];
|
||||||
|
|
@ -1586,8 +1764,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
|
const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
|
||||||
const pkColumn = getPrimaryKeyColumn();
|
const pkColumn = getPrimaryKeyColumn();
|
||||||
const allSelected =
|
const allSelected =
|
||||||
filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
|
filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn] as string | number));
|
||||||
const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
|
const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn] as string | number));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
|
|
@ -1633,7 +1811,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredRightData.map((item, index) => {
|
filteredRightData.map((item, index) => {
|
||||||
const itemId = item[pkColumn];
|
const itemId = item[pkColumn] as string | number;
|
||||||
return (
|
return (
|
||||||
<TableRow key={index} className="hover:bg-muted/50">
|
<TableRow key={index} className="hover:bg-muted/50">
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
|
|
@ -1962,11 +2140,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={btn.variant || "default"}
|
variant={btn.variant || "default"}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
onClick={() => {
|
onClick={() => handleLeftActionButton(btn)}
|
||||||
if (btn.action === "add") {
|
|
||||||
handleLeftAddClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{btn.icon && <span className="mr-1">{btn.icon}</span>}
|
{btn.icon && <span className="mr-1">{btn.icon}</span>}
|
||||||
{btn.label || "버튼"}
|
{btn.label || "버튼"}
|
||||||
|
|
|
||||||
|
|
@ -992,6 +992,42 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 개별 수정/삭제 버튼 (좌측) */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<Label className="text-xs font-medium">개별 수정/삭제</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-[10px]">각 행에 표시되는 수정/삭제 버튼</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">수정 버튼</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.leftPanel?.showEditButton || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("leftPanel.showEditButton", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">삭제 버튼</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.leftPanel?.showDeleteButton || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("leftPanel.showDeleteButton", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Label className="text-xs">기본키 컬럼</Label>
|
||||||
|
<ColumnSelect
|
||||||
|
columns={leftColumns}
|
||||||
|
value={config.leftPanel?.primaryKeyColumn || ""}
|
||||||
|
onValueChange={(value) => updateConfig("leftPanel.primaryKeyColumn", value)}
|
||||||
|
placeholder="기본키 컬럼 선택 (기본: id)"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
수정/삭제 시 레코드 식별에 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 탭 설정 (좌측) */}
|
{/* 탭 설정 (좌측) */}
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -1274,6 +1310,101 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 개별 수정/삭제 버튼 */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<Label className="text-xs font-medium">개별 수정/삭제</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-[10px]">각 행에 표시되는 수정/삭제 버튼</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">수정 버튼</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.rightPanel?.showEditButton || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">삭제 버튼</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.rightPanel?.showDeleteButton || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("rightPanel.showDeleteButton", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Label className="text-xs">기본키 컬럼</Label>
|
||||||
|
<ColumnSelect
|
||||||
|
columns={rightColumns}
|
||||||
|
value={config.rightPanel?.primaryKeyColumn || ""}
|
||||||
|
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
|
||||||
|
placeholder="기본키 컬럼 선택 (기본: id)"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
수정/삭제 시 레코드 식별에 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수정 시 메인 테이블 조회 설정 */}
|
||||||
|
{config.rightPanel?.showEditButton && (
|
||||||
|
<div className="mt-3 border-t pt-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Label className="text-xs font-medium">수정 시 메인 테이블 조회</Label>
|
||||||
|
<Switch
|
||||||
|
checked={!!config.rightPanel?.mainTableForEdit?.tableName}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateConfig("rightPanel.mainTableForEdit", {
|
||||||
|
tableName: "",
|
||||||
|
linkColumn: { mainColumn: "", subColumn: "" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateConfig("rightPanel.mainTableForEdit", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mb-2 text-[10px]">
|
||||||
|
우측 패널이 서브 테이블일 때, 수정 모달에 메인 테이블 데이터도 함께 전달
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{config.rightPanel?.mainTableForEdit && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-2 bg-muted/30">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">메인 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel.mainTableForEdit.tableName || ""}
|
||||||
|
onChange={(e) => updateConfig("rightPanel.mainTableForEdit.tableName", e.target.value)}
|
||||||
|
placeholder="예: user_info"
|
||||||
|
className="h-7 text-xs mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">메인 테이블 연결 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel.mainTableForEdit.linkColumn?.mainColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("rightPanel.mainTableForEdit.linkColumn.mainColumn", e.target.value)}
|
||||||
|
placeholder="예: user_id"
|
||||||
|
className="h-7 text-xs mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">서브 테이블 연결 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel.mainTableForEdit.linkColumn?.subColumn || ""}
|
||||||
|
onChange={(e) => updateConfig("rightPanel.mainTableForEdit.linkColumn.subColumn", e.target.value)}
|
||||||
|
placeholder="예: user_id"
|
||||||
|
className="h-7 text-xs mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 탭 설정 (우측) */}
|
{/* 탭 설정 (우측) */}
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -1348,39 +1479,6 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onCheckedChange={(checked) => updateConfig("rightPanel.showCheckbox", checked)}
|
onCheckedChange={(checked) => updateConfig("rightPanel.showCheckbox", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 수정/삭제 버튼 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">개별 수정/삭제</Label>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs">수정 버튼 표시</Label>
|
|
||||||
<Switch
|
|
||||||
checked={config.rightPanel?.showEditButton || false}
|
|
||||||
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs">삭제 버튼 표시</Label>
|
|
||||||
<Switch
|
|
||||||
checked={config.rightPanel?.showDeleteButton || false}
|
|
||||||
onCheckedChange={(checked) => updateConfig("rightPanel.showDeleteButton", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 기본키 컬럼 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">기본키 컬럼</Label>
|
|
||||||
<ColumnSelect
|
|
||||||
columns={rightColumns}
|
|
||||||
value={config.rightPanel?.primaryKeyColumn || ""}
|
|
||||||
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
|
|
||||||
placeholder="기본키 컬럼 선택 (기본: id)"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
||||||
수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,4 @@ SplitPanelLayout2Renderer.registerSelf();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,3 +161,4 @@ export const SearchableColumnSelect: React.FC<SearchableColumnSelectProps> = ({
|
||||||
|
|
||||||
export default SearchableColumnSelect;
|
export default SearchableColumnSelect;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,4 @@ export const SortableColumnItem: React.FC<SortableColumnItemProps> = ({
|
||||||
|
|
||||||
export default SortableColumnItem;
|
export default SortableColumnItem;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,9 @@ export interface LeftPanelConfig {
|
||||||
showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
|
showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
|
||||||
addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
|
addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
|
||||||
addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
|
addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
|
||||||
|
showEditButton?: boolean; // 수정 버튼 표시
|
||||||
|
showDeleteButton?: boolean; // 삭제 버튼 표시
|
||||||
|
editModalScreenId?: number; // 수정 모달 화면 ID
|
||||||
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
|
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
|
||||||
displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
|
displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
|
||||||
primaryKeyColumn?: string; // 기본키 컬럼명 (선택용, 기본: id)
|
primaryKeyColumn?: string; // 기본키 컬럼명 (선택용, 기본: id)
|
||||||
|
|
@ -208,6 +211,20 @@ export interface RightPanelConfig {
|
||||||
* - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시
|
* - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시
|
||||||
*/
|
*/
|
||||||
joinTables?: JoinTableConfig[];
|
joinTables?: JoinTableConfig[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 시 메인 테이블 데이터 조회 설정
|
||||||
|
* 우측 패널이 서브 테이블(예: user_dept)이고, 수정 모달이 메인 테이블(예: user_info) 기준일 때
|
||||||
|
* 수정 버튼 클릭 시 메인 테이블 데이터를 함께 조회하여 모달에 전달합니다.
|
||||||
|
*/
|
||||||
|
mainTableForEdit?: {
|
||||||
|
tableName: string; // 메인 테이블명 (예: user_info)
|
||||||
|
linkColumn: {
|
||||||
|
mainColumn: string; // 메인 테이블의 연결 컬럼 (예: user_id)
|
||||||
|
subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 탭 설정
|
// 탭 설정
|
||||||
tabConfig?: TabConfig;
|
tabConfig?: TabConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -102,11 +102,13 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
options.map((option) => (
|
options
|
||||||
<SelectItem key={option.value} value={option.value}>
|
.filter((option) => option.value && option.value !== "")
|
||||||
{option.label}
|
.map((option) => (
|
||||||
</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
))
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -212,15 +214,23 @@ export function UniversalFormModalComponent({
|
||||||
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
||||||
const lastInitializedId = useRef<string | undefined>(undefined);
|
const lastInitializedId = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
|
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// initialData에서 ID 값 추출 (id, ID, objid 등)
|
// initialData에서 ID 값 추출 (id, ID, objid 등)
|
||||||
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
|
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
|
||||||
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
|
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
|
||||||
|
|
||||||
|
// 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만)
|
||||||
|
const createModeDataHash = !currentIdString && initialData && Object.keys(initialData).length > 0
|
||||||
|
? JSON.stringify(initialData)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 이미 초기화되었고, ID가 동일하면 스킵
|
// 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵
|
||||||
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
||||||
return;
|
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
|
||||||
|
if (!createModeDataHash || capturedInitialData.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
|
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
|
||||||
|
|
@ -245,7 +255,7 @@ export function UniversalFormModalComponent({
|
||||||
hasInitialized.current = true;
|
hasInitialized.current = true;
|
||||||
initializeForm();
|
initializeForm();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
|
}, [initialData]); // initialData 전체 변경 시 재초기화
|
||||||
|
|
||||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -478,6 +488,82 @@ export function UniversalFormModalComponent({
|
||||||
setActivatedOptionalFieldGroups(newActivatedGroups);
|
setActivatedOptionalFieldGroups(newActivatedGroups);
|
||||||
setOriginalData(effectiveInitialData || {});
|
setOriginalData(effectiveInitialData || {});
|
||||||
|
|
||||||
|
// 수정 모드에서 서브 테이블 데이터 로드 (겸직 등)
|
||||||
|
const multiTable = config.saveConfig?.customApiSave?.multiTable;
|
||||||
|
if (multiTable && effectiveInitialData) {
|
||||||
|
const pkColumn = multiTable.mainTable?.primaryKeyColumn;
|
||||||
|
const pkValue = effectiveInitialData[pkColumn];
|
||||||
|
|
||||||
|
// PK 값이 있으면 수정 모드로 판단
|
||||||
|
if (pkValue) {
|
||||||
|
console.log("[initializeForm] 수정 모드 - 서브 테이블 데이터 로드 시작");
|
||||||
|
|
||||||
|
for (const subTableConfig of multiTable.subTables || []) {
|
||||||
|
// loadOnEdit 옵션이 활성화된 경우에만 로드
|
||||||
|
if (!subTableConfig.enabled || !subTableConfig.options?.loadOnEdit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tableName, linkColumn, repeatSectionId, fieldMappings, options } = subTableConfig;
|
||||||
|
if (!tableName || !linkColumn?.subColumn || !repeatSectionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 서브 테이블에서 데이터 조회
|
||||||
|
const filters: Record<string, any> = {
|
||||||
|
[linkColumn.subColumn]: pkValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 서브 항목만 로드 (메인 항목 제외)
|
||||||
|
if (options?.loadOnlySubItems && options?.mainMarkerColumn) {
|
||||||
|
filters[options.mainMarkerColumn] = options.subMarkerValue ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[initializeForm] 서브 테이블 ${tableName} 조회:`, filters);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/data`, {
|
||||||
|
params: {
|
||||||
|
filters: JSON.stringify(filters),
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success && response.data?.data?.items) {
|
||||||
|
const subItems = response.data.data.items;
|
||||||
|
console.log(`[initializeForm] 서브 테이블 ${tableName} 데이터 ${subItems.length}건 로드됨`);
|
||||||
|
|
||||||
|
// 역매핑: 서브 테이블 데이터 → 반복 섹션 데이터
|
||||||
|
const repeatItems: RepeatSectionItem[] = subItems.map((item: any, index: number) => {
|
||||||
|
const repeatItem: RepeatSectionItem = {
|
||||||
|
_id: generateUniqueId("repeat"),
|
||||||
|
_index: index,
|
||||||
|
_originalData: item, // 원본 데이터 보관 (수정 시 필요)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 매핑 역변환 (targetColumn → formField)
|
||||||
|
for (const mapping of fieldMappings || []) {
|
||||||
|
if (mapping.formField && mapping.targetColumn) {
|
||||||
|
repeatItem[mapping.formField] = item[mapping.targetColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repeatItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 반복 섹션에 데이터 설정
|
||||||
|
newRepeatSections[repeatSectionId] = repeatItems;
|
||||||
|
setRepeatSections({ ...newRepeatSections });
|
||||||
|
console.log(`[initializeForm] 반복 섹션 ${repeatSectionId}에 ${repeatItems.length}건 설정`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[initializeForm] 서브 테이블 ${tableName} 로드 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 채번규칙 자동 생성
|
// 채번규칙 자동 생성
|
||||||
console.log("[initializeForm] generateNumberingValues 호출");
|
console.log("[initializeForm] generateNumberingValues 호출");
|
||||||
await generateNumberingValues(newFormData);
|
await generateNumberingValues(newFormData);
|
||||||
|
|
@ -877,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이 없거나 메인 테이블과 같은 경우
|
// targetTable이 없거나 메인 테이블과 같은 경우
|
||||||
const tableSectionsForMainTable = config.sections.filter(
|
const tableSectionsForMainTable = config.sections.filter(
|
||||||
|
|
@ -885,6 +978,12 @@ export function UniversalFormModalComponent({
|
||||||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
|
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) {
|
if (tableSectionsForMainTable.length > 0) {
|
||||||
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
||||||
const commonFieldsData: Record<string, any> = {};
|
const commonFieldsData: Record<string, any> = {};
|
||||||
|
|
@ -964,48 +1063,83 @@ export function UniversalFormModalComponent({
|
||||||
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
|
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
|
||||||
const mainRecordId = response.data?.data?.id;
|
const mainRecordId = response.data?.data?.id;
|
||||||
|
|
||||||
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
// 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
|
||||||
|
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
|
||||||
const commonFieldsData: Record<string, any> = {};
|
const commonFieldsData: Record<string, any> = {};
|
||||||
const { sectionSaveModes } = config.saveConfig;
|
const { sectionSaveModes } = config.saveConfig;
|
||||||
|
|
||||||
if (sectionSaveModes && sectionSaveModes.length > 0) {
|
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
|
||||||
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
|
for (const otherSection of config.sections) {
|
||||||
for (const otherSection of config.sections) {
|
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
|
||||||
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
|
|
||||||
|
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
|
||||||
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
|
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
|
||||||
const defaultMode = otherSection.type === "table" ? "individual" : "common";
|
const defaultMode = otherSection.type === "table" ? "individual" : "common";
|
||||||
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||||
|
|
||||||
// 필드 타입 섹션의 필드들 처리
|
// 필드 타입 섹션의 필드들 처리
|
||||||
if (otherSection.type !== "table" && otherSection.fields) {
|
if (otherSection.type !== "table" && otherSection.fields) {
|
||||||
for (const field of otherSection.fields) {
|
for (const field of otherSection.fields) {
|
||||||
// 필드별 오버라이드 확인
|
// 필드별 오버라이드 확인
|
||||||
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||||
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||||
|
|
||||||
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
|
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
|
||||||
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
|
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
|
||||||
commonFieldsData[field.columnName] = formData[field.columnName];
|
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) {
|
for (const item of sectionData) {
|
||||||
// 공통 필드 병합 + 개별 품목 데이터
|
// 공통 필드 병합 + 개별 품목 데이터
|
||||||
const itemToSave = { ...commonFieldsData, ...item };
|
const itemToSave = { ...commonFieldsData, ...item };
|
||||||
|
|
||||||
|
// saveToTarget: false인 컬럼은 저장에서 제외
|
||||||
|
const columns = section.tableConfig?.columns || [];
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) {
|
||||||
|
delete itemToSave[col.field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _sourceData 등 내부 메타데이터 제거
|
||||||
|
Object.keys(itemToSave).forEach((key) => {
|
||||||
|
if (key.startsWith("_")) {
|
||||||
|
delete itemToSave[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 메인 레코드와 연결이 필요한 경우
|
// 메인 레코드와 연결이 필요한 경우
|
||||||
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
||||||
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.post(
|
const saveResponse = await apiClient.post(
|
||||||
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
|
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
|
||||||
itemToSave
|
itemToSave
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!saveResponse.data?.success) {
|
||||||
|
throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1142,6 +1276,20 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 1-0. receiveFromParent 필드 값도 mainData에 추가 (서브 테이블 저장용)
|
||||||
|
// 이 필드들은 메인 테이블에는 저장되지 않지만, 서브 테이블 저장 시 필요할 수 있음
|
||||||
|
config.sections.forEach((section) => {
|
||||||
|
if (section.repeatable || section.type === "table") return;
|
||||||
|
(section.fields || []).forEach((field) => {
|
||||||
|
if (field.receiveFromParent && !mainData[field.columnName]) {
|
||||||
|
const value = formData[field.columnName];
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
mainData[field.columnName] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
|
|
@ -1185,36 +1333,42 @@ export function UniversalFormModalComponent({
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const subTableConfig of multiTable.subTables || []) {
|
for (const subTableConfig of multiTable.subTables || []) {
|
||||||
if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
|
// 서브 테이블이 활성화되어 있고 테이블명이 있어야 함
|
||||||
|
// repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음)
|
||||||
|
if (!subTableConfig.enabled || !subTableConfig.tableName) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subItems: Record<string, any>[] = [];
|
const subItems: Record<string, any>[] = [];
|
||||||
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
|
|
||||||
|
// 반복 섹션이 있는 경우에만 반복 데이터 처리
|
||||||
|
if (subTableConfig.repeatSectionId) {
|
||||||
|
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
|
||||||
|
|
||||||
// 반복 섹션 데이터를 필드 매핑에 따라 변환
|
// 반복 섹션 데이터를 필드 매핑에 따라 변환
|
||||||
for (const item of repeatData) {
|
for (const item of repeatData) {
|
||||||
const mappedItem: Record<string, any> = {};
|
const mappedItem: Record<string, any> = {};
|
||||||
|
|
||||||
// 연결 컬럼 값 설정
|
// 연결 컬럼 값 설정
|
||||||
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
|
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
|
||||||
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
|
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
|
||||||
}
|
|
||||||
|
|
||||||
// 필드 매핑에 따라 데이터 변환
|
|
||||||
for (const mapping of subTableConfig.fieldMappings || []) {
|
|
||||||
if (mapping.formField && mapping.targetColumn) {
|
|
||||||
mappedItem[mapping.targetColumn] = item[mapping.formField];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
|
// 필드 매핑에 따라 데이터 변환
|
||||||
if (subTableConfig.options?.mainMarkerColumn) {
|
for (const mapping of subTableConfig.fieldMappings || []) {
|
||||||
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
|
if (mapping.formField && mapping.targetColumn) {
|
||||||
}
|
mappedItem[mapping.targetColumn] = item[mapping.formField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(mappedItem).length > 0) {
|
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
|
||||||
subItems.push(mappedItem);
|
if (subTableConfig.options?.mainMarkerColumn) {
|
||||||
|
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mappedItem).length > 0) {
|
||||||
|
subItems.push(mappedItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1223,12 +1377,12 @@ export function UniversalFormModalComponent({
|
||||||
if (subTableConfig.options?.saveMainAsFirst) {
|
if (subTableConfig.options?.saveMainAsFirst) {
|
||||||
mainFieldMappings = [];
|
mainFieldMappings = [];
|
||||||
|
|
||||||
// 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
|
// fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만)
|
||||||
// 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
|
|
||||||
for (const mapping of subTableConfig.fieldMappings || []) {
|
for (const mapping of subTableConfig.fieldMappings || []) {
|
||||||
if (mapping.targetColumn) {
|
if (mapping.targetColumn) {
|
||||||
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
|
// formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함)
|
||||||
if (mainData[mapping.targetColumn] !== undefined) {
|
const formValue = formData[mapping.targetColumn];
|
||||||
|
if (formValue !== undefined && formValue !== null && formValue !== "") {
|
||||||
mainFieldMappings.push({
|
mainFieldMappings.push({
|
||||||
formField: mapping.targetColumn,
|
formField: mapping.targetColumn,
|
||||||
targetColumn: mapping.targetColumn,
|
targetColumn: mapping.targetColumn,
|
||||||
|
|
@ -1239,11 +1393,14 @@ export function UniversalFormModalComponent({
|
||||||
config.sections.forEach((section) => {
|
config.sections.forEach((section) => {
|
||||||
if (section.repeatable || section.type === "table") return;
|
if (section.repeatable || section.type === "table") return;
|
||||||
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
|
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
|
||||||
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
if (matchingField) {
|
||||||
mainFieldMappings!.push({
|
const fieldValue = formData[matchingField.columnName];
|
||||||
formField: matchingField.columnName,
|
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") {
|
||||||
targetColumn: mapping.targetColumn,
|
mainFieldMappings!.push({
|
||||||
});
|
formField: matchingField.columnName,
|
||||||
|
targetColumn: mapping.targetColumn,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1256,15 +1413,18 @@ export function UniversalFormModalComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
subTablesData.push({
|
// 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가)
|
||||||
tableName: subTableConfig.tableName,
|
if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) {
|
||||||
linkColumn: subTableConfig.linkColumn,
|
subTablesData.push({
|
||||||
items: subItems,
|
tableName: subTableConfig.tableName,
|
||||||
options: {
|
linkColumn: subTableConfig.linkColumn,
|
||||||
...subTableConfig.options,
|
items: subItems,
|
||||||
mainFieldMappings, // 메인 데이터 매핑 추가
|
options: {
|
||||||
},
|
...subTableConfig.options,
|
||||||
});
|
mainFieldMappings, // 메인 데이터 매핑 추가
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 범용 다중 테이블 저장 API 호출
|
// 3. 범용 다중 테이블 저장 API 호출
|
||||||
|
|
@ -1374,6 +1534,11 @@ export function UniversalFormModalComponent({
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
onSave({ ...formData, _saveCompleted: true });
|
onSave({ ...formData, _saveCompleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 저장 완료 후 모달 닫기 이벤트 발생
|
||||||
|
if (config.saveConfig.afterSave?.closeModal !== false) {
|
||||||
|
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("저장 실패:", error);
|
console.error("저장 실패:", error);
|
||||||
// axios 에러의 경우 서버 응답 메시지 추출
|
// axios 에러의 경우 서버 응답 메시지 추출
|
||||||
|
|
@ -1485,16 +1650,39 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 표시 텍스트 생성 함수
|
// 표시 텍스트 생성 함수
|
||||||
const getDisplayText = (row: Record<string, unknown>): string => {
|
const getDisplayText = (row: Record<string, unknown>): string => {
|
||||||
const displayVal = row[lfg.displayColumn || ""] || "";
|
// 메인 표시 컬럼 (displayColumn)
|
||||||
const valueVal = row[valueColumn] || "";
|
const mainDisplayVal = row[lfg.displayColumn || ""] || "";
|
||||||
|
// 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용)
|
||||||
|
const subDisplayVal = lfg.subDisplayColumn
|
||||||
|
? (row[lfg.subDisplayColumn] || "")
|
||||||
|
: (row[valueColumn] || "");
|
||||||
|
|
||||||
switch (lfg.displayFormat) {
|
switch (lfg.displayFormat) {
|
||||||
case "code_name":
|
case "code_name":
|
||||||
return `${valueVal} - ${displayVal}`;
|
// 서브 - 메인 형식
|
||||||
|
return `${subDisplayVal} - ${mainDisplayVal}`;
|
||||||
case "name_code":
|
case "name_code":
|
||||||
return `${displayVal} (${valueVal})`;
|
// 메인 (서브) 형식
|
||||||
|
return `${mainDisplayVal} (${subDisplayVal})`;
|
||||||
|
case "custom":
|
||||||
|
// 커스텀 형식: {컬럼명}을 실제 값으로 치환
|
||||||
|
if (lfg.customDisplayFormat) {
|
||||||
|
let result = lfg.customDisplayFormat;
|
||||||
|
// {컬럼명} 패턴을 찾아서 실제 값으로 치환
|
||||||
|
const matches = result.match(/\{([^}]+)\}/g);
|
||||||
|
if (matches) {
|
||||||
|
matches.forEach((match) => {
|
||||||
|
const columnName = match.slice(1, -1); // { } 제거
|
||||||
|
const columnValue = row[columnName];
|
||||||
|
result = result.replace(match, columnValue !== undefined && columnValue !== null ? String(columnValue) : "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return String(mainDisplayVal);
|
||||||
case "name_only":
|
case "name_only":
|
||||||
default:
|
default:
|
||||||
return String(displayVal);
|
return String(mainDisplayVal);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1542,11 +1730,13 @@ export function UniversalFormModalComponent({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sourceData.length > 0 ? (
|
{sourceData.length > 0 ? (
|
||||||
sourceData.map((row, index) => (
|
sourceData
|
||||||
<SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
|
.filter((row) => row[valueColumn] !== null && row[valueColumn] !== undefined && String(row[valueColumn]) !== "")
|
||||||
{getDisplayText(row)}
|
.map((row, index) => (
|
||||||
</SelectItem>
|
<SelectItem key={`${row[valueColumn]}_${index}`} value={String(row[valueColumn])}>
|
||||||
))
|
{getDisplayText(row)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<SelectItem value="_empty" disabled>
|
<SelectItem value="_empty" disabled>
|
||||||
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
|
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
|
||||||
|
|
@ -2207,11 +2397,13 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
||||||
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{options.map((option) => (
|
{options
|
||||||
<SelectItem key={option.value} value={option.value}>
|
.filter((option) => option.value && option.value !== "")
|
||||||
{option.label}
|
.map((option) => (
|
||||||
</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
))}
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -728,13 +728,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
||||||
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
||||||
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
|
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
||||||
{section.tableConfig.columns.slice(0, 4).map((col) => (
|
{section.tableConfig.columns.slice(0, 4).map((col, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={col.field}
|
key={col.field || `col_${idx}`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
|
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label || col.field || `컬럼 ${idx + 1}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{section.tableConfig.columns.length > 4 && (
|
{section.tableConfig.columns.length > 4 && (
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue