chpark-sync #425

Merged
kjs merged 293 commits from chpark-sync into main 2026-03-23 09:36:36 +09:00
81 changed files with 6119 additions and 630 deletions
Showing only changes of commit 5dae0f016c - Show all commits

View File

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

View File

@ -1973,15 +1973,21 @@ export async function multiTableSave(
for (const subTableConfig of subTables || []) {
const { tableName, linkColumn, items, options } = subTableConfig;
if (!tableName || !items || items.length === 0) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
itemsCount: items.length,
itemsCount: items?.length || 0,
linkColumn,
options,
hasSaveMainAsFirst,
});
// 기존 데이터 삭제 옵션
@ -1999,7 +2005,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> = {
[linkColumn.subColumn]: savedPkValue,
};

View File

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

View File

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

View File

@ -336,7 +336,7 @@ export default function SentMailPage() {
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
</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" />
</Button>

View File

@ -168,7 +168,7 @@ export default function AdminPage() {
</div>
</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="flex items-center gap-4">
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
@ -182,7 +182,7 @@ export default function AdminPage() {
</div>
</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="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">

View File

@ -180,7 +180,7 @@ export default function DashboardListClient() {
<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">
<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>
@ -292,7 +292,7 @@ export default function DashboardListClient() {
<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}`)}
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
{dashboard.title}
@ -319,7 +319,7 @@ export default function DashboardListClient() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
className="gap-2 text-sm"
>
<Edit className="h-4 w-4" />
@ -356,7 +356,7 @@ export default function DashboardListClient() {
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<button
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
>
<h3 className="text-base font-semibold">{dashboard.title}</h3>
@ -391,7 +391,7 @@ export default function DashboardListClient() {
variant="outline"
size="sm"
className="h-9 flex-1 gap-2 text-sm"
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
>
<Edit className="h-4 w-4" />

View File

@ -1,4 +1,4 @@
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient";
/**
*

View File

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

View File

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

View File

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

View File

@ -142,7 +142,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
{/* *\/}
<button
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"
>

View File

@ -130,7 +130,7 @@ export default function DashboardListPage() {
</div>
<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"
>
@ -185,7 +185,7 @@ export default function DashboardListPage() {
</p>
{!searchTerm && (
<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"
>
@ -251,7 +251,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
</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"
>

View File

@ -22,7 +22,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
// 부서 관리 페이지로 이동
const handleManageDepartments = (company: Company) => {
router.push(`/admin/company/${company.company_code}/departments`);
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
};
// 디스크 사용량 포맷팅 함수

View File

@ -236,7 +236,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
<Button variant="outline" onClick={() => router.push("/admin/roles")}>
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
</Button>
</div>
@ -248,7 +248,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/roles")} className="h-10 w-10">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">

View File

@ -141,7 +141,7 @@ export function RoleManagement() {
// 상세 페이지로 이동
const handleViewDetail = useCallback(
(role: RoleGroup) => {
router.push(`/admin/roles/${role.objid}`);
router.push(`/admin/userMng/rolesList/${role.objid}`);
},
[router],
);

View File

@ -643,7 +643,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
router.push("/admin/screenMng/dashboardList");
}}
>
<DialogContent className="sm:max-w-md">
@ -660,7 +660,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
<Button
onClick={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
router.push("/admin/screenMng/dashboardList");
}}
>

View File

@ -91,7 +91,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
<Label className="text-foreground text-xs font-medium"> </Label>
<button
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"
>
@ -124,7 +124,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
<div className="text-warning mb-1 text-xs"> </div>
<button
onClick={() => {
router.push("/admin/external-connections");
router.push("/admin/automaticMng/exconList");
}}
className="text-warning text-[11px] underline hover:no-underline"
>

View File

@ -45,7 +45,7 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps)
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/company");
router.push("/admin/userMng/companyList");
};
return (

View File

@ -250,7 +250,7 @@ export default function MailDetailModal({
originalDate: mail.date,
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();
}}
>
@ -270,7 +270,7 @@ export default function MailDetailModal({
originalBody: mail.body,
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();
}}
>

View File

@ -49,7 +49,7 @@ export function ReportListTable({
// 수정
const handleEdit = (reportId: string) => {
router.push(`/admin/report/designer/${reportId}`);
router.push(`/admin/screenMng/reportList/designer/${reportId}`);
};
// 복사

View File

@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
selectedComponentId,
selectedComponentIds,
selectComponent,
selectMultipleComponents,
updateComponent,
getQueryResult,
snapValueToGrid,
@ -178,20 +179,192 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
margins,
layoutConfig,
currentPageId,
duplicateAtPosition,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
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 isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
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) => {
// 편집 모드에서는 드래그 비활성화
if (isEditing) return;
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
@ -209,16 +382,83 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
// Alt 키 감지 (복제 드래그)
const isAltPressed = e.altKey;
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
if (isGrouped && !isMultiSelect) {
const groupMembers = components.filter((c) => c.groupId === component.groupId);
const groupMemberIds = groupMembers.map((c) => c.id);
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
selectComponent(groupMemberIds[0], false);
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
} else {
selectComponent(component.id, isMultiSelect);
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
if (!isPartOfMultiSelection) {
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
if (isGrouped && !isMultiSelect) {
const groupMembers = components.filter((c) => c.groupId === component.groupId);
const groupMemberIds = groupMembers.map((c) => c.id);
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
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);
@ -284,14 +524,58 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const deltaX = snappedX - component.x;
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, {
x: snappedX,
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) => {
if (c.groupId === component.groupId && c.id !== component.id) {
const newGroupX = c.x + deltaX;
@ -369,6 +653,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// Alt 복제 상태 초기화
setIsAltDuplicating(false);
duplicatedIdsRef.current = [];
originalPositionsRef.current = new Map();
// 가이드라인 초기화
clearAlignmentGuides();
};
@ -383,6 +671,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}, [
isDragging,
isResizing,
isAltDuplicating,
dragStart.x,
dragStart.y,
resizeStart.x,
@ -405,36 +694,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
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 displayValue = getDisplayValue();
@ -443,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
switch (component.type) {
case "text":
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 (
<div
className="h-full w-full"
@ -1182,6 +1462,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
: "1px solid #e5e7eb",
}}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
>
{renderContent()}

View File

@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig, WatermarkConfig } from "@/types/report";
@ -201,6 +201,7 @@ export function ReportDesignerCanvas() {
canvasHeight,
margins,
selectComponent,
selectMultipleComponents,
selectedComponentId,
selectedComponentIds,
removeComponent,
@ -210,12 +211,32 @@ export function ReportDesignerCanvas() {
alignmentGuides,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
showRuler,
layoutConfig,
} = 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(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
@ -420,12 +441,127 @@ export function ReportDesignerCanvas() {
}),
}));
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
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);
}
};
// 드래그 영역 선택 시작
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, 화살표 이동)
useEffect(() => {
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): 복사
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
copyComponents();
return;
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
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보다 먼저 체크)
@ -538,6 +704,10 @@ export function ReportDesignerCanvas() {
removeComponent,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
]);
@ -592,8 +762,10 @@ export function ReportDesignerCanvas() {
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
cursor: isMarqueeSelecting ? "crosshair" : "default",
}}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
>
{/* 페이지 여백 가이드 */}
{currentPage && (
@ -648,6 +820,20 @@ export function ReportDesignerCanvas() {
<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 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">

View File

@ -140,7 +140,7 @@ export function ReportDesignerToolbar() {
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
await saveLayoutWithMenus(selectedMenuObjids);
if (pendingSaveAndClose) {
router.push("/admin/report");
router.push("/admin/screenMng/reportList");
}
};
@ -151,7 +151,7 @@ export function ReportDesignerToolbar() {
const handleBackConfirm = () => {
setShowBackConfirm(false);
router.push("/admin/report");
router.push("/admin/screenMng/reportList");
};
const handleSaveAsTemplate = async (data: {

View File

@ -996,14 +996,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
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 (
<InteractiveScreenViewerDynamic
key={component.id}

View File

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

View File

@ -63,6 +63,7 @@ interface ReportDesignerContextType {
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택
// 레이아웃 관리
updateLayout: (updates: Partial<ReportLayout>) => void;
@ -100,6 +101,11 @@ interface ReportDesignerContextType {
// 복사/붙여넣기
copyComponents: () => 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: () => void;
@ -267,6 +273,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 클립보드 (복사/붙여넣기)
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
// 스타일 클립보드 (스타일만 복사/붙여넣기)
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | null>(null);
// Undo/Redo 히스토리
const [history, setHistory] = useState<ComponentConfig[][]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
@ -284,7 +293,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사 (Ctrl+C)
const copyComponents = useCallback(() => {
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);
toast({
title: "복사 완료",
@ -293,6 +313,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
} else if (selectedComponentId) {
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
if (componentToCopy) {
// 잠긴 컴포넌트는 복사 불가
if (componentToCopy.locked) {
toast({
title: "복사 불가",
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
variant: "destructive",
});
return;
}
setClipboard([componentToCopy]);
toast({
title: "복사 완료",
@ -332,6 +361,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
});
}, [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(
(newComponents: ComponentConfig[]) => {
@ -1292,6 +1504,114 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
[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(
(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(
(updates: Partial<ReportLayout>) => {
@ -1639,6 +1970,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
updateComponent,
removeComponent,
selectComponent,
selectMultipleComponents,
updateLayout,
saveLayout,
loadLayout,
@ -1662,6 +1994,11 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사/붙여넣기
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
duplicateAtPosition,
fitSelectedToContent,
// Undo/Redo
undo,
redo,

View File

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

View File

@ -1636,7 +1636,7 @@ export function ModalRepeaterTableConfigPanel({
</SelectValue>
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label} ({col.field})
</SelectItem>
@ -1900,7 +1900,7 @@ export function ModalRepeaterTableConfigPanel({
<SelectValue placeholder="현재 행 필드" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
@ -2056,7 +2056,7 @@ export function ModalRepeaterTableConfigPanel({
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label} ({col.field})
</SelectItem>
@ -2303,7 +2303,7 @@ export function ModalRepeaterTableConfigPanel({
<SelectValue placeholder="현재 행 필드" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>

View File

@ -481,7 +481,7 @@ export function RepeaterTable({
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View File

@ -561,7 +561,7 @@ export function SimpleRepeaterTableComponent({
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View File

@ -1539,7 +1539,7 @@ export function SimpleRepeaterTableConfigPanel({
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<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}>
{col.label}
</SelectItem>

View File

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

View File

@ -672,3 +672,4 @@ export const ActionButtonConfigModal: React.FC<ActionButtonConfigModalProps> = (
export default ActionButtonConfigModal;

View File

@ -803,3 +803,4 @@ export const ColumnConfigModal: React.FC<ColumnConfigModalProps> = ({
export default ColumnConfigModal;

View File

@ -675,7 +675,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 우측 패널 수정 버튼 클릭
const handleEditItem = useCallback(
(item: any) => {
async (item: any) => {
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
@ -684,13 +684,42 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
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: item, // 기존 데이터 전달
editData: editData, // 병합된 데이터 전달
isCreateMode: false, // 수정 모드
onSave: () => {
if (selectedLeftItem) {
@ -700,9 +729,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
},
});
window.dispatchEvent(event);
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", item);
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
},
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData],
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData],
);
// 좌측 패널 수정 버튼 클릭
@ -791,11 +820,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
toast.success(`${itemsToDelete.length}개 항목이 삭제되었습니다.`);
setSelectedRightItems(new Set<string | number>());
} else if (itemToDelete) {
// 단일 삭제 - 해당 항목 데이터를 body로 전달
// 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함)
console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete);
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
data: itemToDelete,
data: [itemToDelete],
});
toast.success("항목이 삭제되었습니다.");
}

View File

@ -1343,6 +1343,65 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</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>

View File

@ -42,3 +42,4 @@ SplitPanelLayout2Renderer.registerSelf();

View File

@ -161,3 +161,4 @@ export const SearchableColumnSelect: React.FC<SearchableColumnSelectProps> = ({
export default SearchableColumnSelect;

View File

@ -116,3 +116,4 @@ export const SortableColumnItem: React.FC<SortableColumnItemProps> = ({
export default SortableColumnItem;

View File

@ -211,6 +211,20 @@ export interface RightPanelConfig {
* - 결과: 부서별 ,
*/
joinTables?: JoinTableConfig[];
/**
*
* (: user_dept), (: user_info)
* .
*/
mainTableForEdit?: {
tableName: string; // 메인 테이블명 (예: user_info)
linkColumn: {
mainColumn: string; // 메인 테이블의 연결 컬럼 (예: user_id)
subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id)
};
};
// 탭 설정
tabConfig?: TabConfig;
}

View File

@ -102,11 +102,13 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
@ -212,15 +214,23 @@ export function UniversalFormModalComponent({
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined);
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
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) {
return;
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
return;
}
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
@ -245,7 +255,7 @@ export function UniversalFormModalComponent({
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
}, [initialData]); // initialData 전체 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
@ -478,6 +488,82 @@ export function UniversalFormModalComponent({
setActivatedOptionalFieldGroups(newActivatedGroups);
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 호출");
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이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
@ -885,6 +978,12 @@ export function UniversalFormModalComponent({
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
);
console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id));
console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id));
console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
@ -964,48 +1063,83 @@ export function UniversalFormModalComponent({
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id;
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
// 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
if (sectionSaveModes && sectionSaveModes.length > 0) {
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
// 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리
if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) {
for (const optGroup of otherSection.optionalFieldGroups) {
if (optGroup.fields) {
for (const field of optGroup.fields) {
// 선택적 필드 그룹은 기본적으로 common 저장
if (formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
}
}
}
console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData));
for (const item of sectionData) {
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item };
// 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) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
}
await apiClient.post(
const saveResponse = await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave
);
if (!saveResponse.data?.success) {
throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
}
}
}
}
@ -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. 채번규칙 처리 (저장 시점에 실제 순번 할당)
for (const section of config.sections) {
@ -1185,36 +1333,42 @@ export function UniversalFormModalComponent({
}> = [];
for (const subTableConfig of multiTable.subTables || []) {
if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
// 서브 테이블이 활성화되어 있고 테이블명이 있어야 함
// repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음)
if (!subTableConfig.enabled || !subTableConfig.tableName) {
continue;
}
const subItems: Record<string, any>[] = [];
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
// 반복 섹션이 있는 경우에만 반복 데이터 처리
if (subTableConfig.repeatSectionId) {
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
// 반복 섹션 데이터를 필드 매핑에 따라 변환
for (const item of repeatData) {
const mappedItem: Record<string, any> = {};
// 반복 섹션 데이터를 필드 매핑에 따라 변환
for (const item of repeatData) {
const mappedItem: Record<string, any> = {};
// 연결 컬럼 값 설정
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
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.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
}
}
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
if (subTableConfig.options?.mainMarkerColumn) {
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
}
// 필드 매핑에 따라 데이터 변환
for (const mapping of subTableConfig.fieldMappings || []) {
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);
}
}
}
@ -1226,8 +1380,9 @@ export function UniversalFormModalComponent({
// fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만)
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.targetColumn) {
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") {
// formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함)
const formValue = formData[mapping.targetColumn];
if (formValue !== undefined && formValue !== null && formValue !== "") {
mainFieldMappings.push({
formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
@ -1238,11 +1393,14 @@ export function UniversalFormModalComponent({
config.sections.forEach((section) => {
if (section.repeatable || section.type === "table") return;
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
if (matchingField) {
const fieldValue = formData[matchingField.columnName];
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
}
}
});
}
@ -1255,15 +1413,18 @@ export function UniversalFormModalComponent({
);
}
subTablesData.push({
tableName: subTableConfig.tableName,
linkColumn: subTableConfig.linkColumn,
items: subItems,
options: {
...subTableConfig.options,
mainFieldMappings, // 메인 데이터 매핑 추가
},
});
// 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가)
if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) {
subTablesData.push({
tableName: subTableConfig.tableName,
linkColumn: subTableConfig.linkColumn,
items: subItems,
options: {
...subTableConfig.options,
mainFieldMappings, // 메인 데이터 매핑 추가
},
});
}
}
// 3. 범용 다중 테이블 저장 API 호출
@ -1489,13 +1650,20 @@ export function UniversalFormModalComponent({
// 표시 텍스트 생성 함수
const getDisplayText = (row: Record<string, unknown>): string => {
const displayVal = row[lfg.displayColumn || ""] || "";
const valueVal = row[valueColumn] || "";
// 메인 표시 컬럼 (displayColumn)
const mainDisplayVal = row[lfg.displayColumn || ""] || "";
// 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용)
const subDisplayVal = lfg.subDisplayColumn
? (row[lfg.subDisplayColumn] || "")
: (row[valueColumn] || "");
switch (lfg.displayFormat) {
case "code_name":
return `${valueVal} - ${displayVal}`;
// 서브 - 메인 형식
return `${subDisplayVal} - ${mainDisplayVal}`;
case "name_code":
return `${displayVal} (${valueVal})`;
// 메인 (서브) 형식
return `${mainDisplayVal} (${subDisplayVal})`;
case "custom":
// 커스텀 형식: {컬럼명}을 실제 값으로 치환
if (lfg.customDisplayFormat) {
@ -1511,10 +1679,10 @@ export function UniversalFormModalComponent({
}
return result;
}
return String(displayVal);
return String(mainDisplayVal);
case "name_only":
default:
return String(displayVal);
return String(mainDisplayVal);
}
};
@ -1562,11 +1730,13 @@ export function UniversalFormModalComponent({
</SelectTrigger>
<SelectContent>
{sourceData.length > 0 ? (
sourceData.map((row, index) => (
<SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
{getDisplayText(row)}
</SelectItem>
))
sourceData
.filter((row) => row[valueColumn] !== null && row[valueColumn] !== undefined && String(row[valueColumn]) !== "")
.map((row, index) => (
<SelectItem key={`${row[valueColumn]}_${index}`} value={String(row[valueColumn])}>
{getDisplayText(row)}
</SelectItem>
))
) : (
<SelectItem value="_empty" disabled>
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
@ -2227,11 +2397,13 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);

View File

@ -728,13 +728,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
{/* 테이블 컬럼 목록 (테이블 타입만) */}
{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">
{section.tableConfig.columns.slice(0, 4).map((col) => (
{section.tableConfig.columns.slice(0, 4).map((col, idx) => (
<Badge
key={col.field}
key={col.field || `col_${idx}`}
variant="outline"
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>
))}
{section.tableConfig.columns.length > 4 && (

View File

@ -11,6 +11,8 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
ConditionalTableConfig,
ConditionalTableOption,
} from "./types";
// 기본 설정값
@ -133,6 +135,33 @@ export const defaultTableSectionConfig: TableSectionConfig = {
multiSelect: true,
maxHeight: "400px",
},
conditionalTable: undefined,
};
// 기본 조건부 테이블 설정
export const defaultConditionalTableConfig: ConditionalTableConfig = {
enabled: false,
triggerType: "checkbox",
conditionColumn: "",
options: [],
optionSource: {
enabled: false,
tableName: "",
valueColumn: "",
labelColumn: "",
filterCondition: "",
},
sourceFilter: {
enabled: false,
filterColumn: "",
},
};
// 기본 조건부 테이블 옵션 설정
export const defaultConditionalTableOptionConfig: ConditionalTableOption = {
id: "",
value: "",
label: "",
};
// 기본 테이블 컬럼 설정
@ -300,3 +329,8 @@ export const generateColumnModeId = (): string => {
export const generateFilterId = (): string => {
return generateUniqueId("filter");
};
// 유틸리티: 조건부 테이블 옵션 ID 생성
export const generateConditionalOptionId = (): string => {
return generateUniqueId("cond");
};

View File

@ -98,6 +98,9 @@ export function FieldDetailSettingsModal({
// Combobox 열림 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태
const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState<Record<number, boolean>>({});
// open이 변경될 때마다 필드 데이터 동기화
useEffect(() => {
@ -105,6 +108,16 @@ export function FieldDetailSettingsModal({
setLocalField(field);
}
}, [open, field]);
// 모달이 열릴 때 소스 테이블 컬럼 자동 로드
useEffect(() => {
if (open && field.linkedFieldGroup?.sourceTable) {
// tableColumns에 해당 테이블 컬럼이 없으면 로드
if (!tableColumns[field.linkedFieldGroup.sourceTable] || tableColumns[field.linkedFieldGroup.sourceTable].length === 0) {
onLoadTableColumns(field.linkedFieldGroup.sourceTable);
}
}
}, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]);
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
useEffect(() => {
@ -735,32 +748,108 @@ export function FieldDetailSettingsModal({
<HelpText> (: customer_mng)</HelpText>
</div>
{/* 표시 형식 선택 */}
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-[10px]"> </Label>
<Select
value={localField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayFormat: value as "name_only" | "code_name" | "name_code",
// name_only 선택 시 서브 컬럼 초기화
...(value === "name_only" ? { subDisplayColumn: undefined } : {}),
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex flex-col">
<span>{opt.label}</span>
<span className="text-[10px] text-muted-foreground">
{opt.value === "name_only" && "메인 컬럼만 표시"}
{opt.value === "code_name" && "서브 - 메인 형식"}
{opt.value === "name_code" && "메인 (서브) 형식"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
{/* 메인 표시 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={localField.linkedFieldGroup?.displayColumn || ""}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={displayColumnOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localField.linkedFieldGroup?.displayColumn
? (() => {
const selectedCol = sourceTableColumns.find(
(c) => c.name === localField.linkedFieldGroup?.displayColumn
);
return selectedCol
? `${selectedCol.name} (${selectedCol.label})`
: localField.linkedFieldGroup?.displayColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs text-center">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label}`}
onSelect={() => {
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayColumn: col.name,
},
});
setDisplayColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localField.linkedFieldGroup?.displayColumn === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.label})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localField.linkedFieldGroup?.displayColumn || ""}
@ -772,39 +861,133 @@ export function FieldDetailSettingsModal({
},
})
}
placeholder="customer_name"
placeholder="item_name"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> (: customer_name)</HelpText>
<HelpText> (: item_name)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.linkedFieldGroup?.displayFormat || "name_only"}
onValueChange={(value) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
displayFormat: value as "name_only" | "code_name" | "name_code",
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
{/* 서브 표시 컬럼 - 표시 형식이 name_only가 아닌 경우에만 표시 */}
{localField.linkedFieldGroup?.displayFormat &&
localField.linkedFieldGroup.displayFormat !== "name_only" && (
<div>
<Label className="text-[10px]"> </Label>
{sourceTableColumns.length > 0 ? (
<Popover open={subDisplayColumnOpen} onOpenChange={setSubDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDisplayColumnOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localField.linkedFieldGroup?.subDisplayColumn
? (() => {
const selectedCol = sourceTableColumns.find(
(c) => c.name === localField.linkedFieldGroup?.subDisplayColumn
);
return selectedCol
? `${selectedCol.name} (${selectedCol.label})`
: localField.linkedFieldGroup?.subDisplayColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs text-center">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label}`}
onSelect={() => {
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
subDisplayColumn: col.name,
},
});
setSubDisplayColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localField.linkedFieldGroup?.subDisplayColumn === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.label})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localField.linkedFieldGroup?.subDisplayColumn || ""}
onChange={(e) =>
updateField({
linkedFieldGroup: {
...localField.linkedFieldGroup,
subDisplayColumn: e.target.value,
},
})
}
placeholder="item_code"
className="h-7 text-xs mt-1"
/>
)}
<HelpText>
{localField.linkedFieldGroup?.displayFormat === "code_name"
? "메인 앞에 표시될 서브 컬럼 (예: 서브 - 메인)"
: "메인 뒤에 표시될 서브 컬럼 (예: 메인 (서브))"}
</HelpText>
</div>
)}
{/* 미리보기 - 메인 컬럼이 선택된 경우에만 표시 */}
{localField.linkedFieldGroup?.displayColumn && (
<div className="p-3 bg-muted/50 rounded-lg border border-dashed">
<p className="text-[10px] text-muted-foreground mb-2">:</p>
{(() => {
const mainCol = localField.linkedFieldGroup?.displayColumn || "";
const subCol = localField.linkedFieldGroup?.subDisplayColumn || "";
const mainLabel = sourceTableColumns.find(c => c.name === mainCol)?.label || mainCol;
const subLabel = sourceTableColumns.find(c => c.name === subCol)?.label || subCol;
const format = localField.linkedFieldGroup?.displayFormat || "name_only";
let preview = "";
if (format === "name_only") {
preview = mainLabel;
} else if (format === "code_name" && subCol) {
preview = `${subLabel} - ${mainLabel}`;
} else if (format === "name_code" && subCol) {
preview = `${mainLabel} (${subLabel})`;
} else if (format !== "name_only" && !subCol) {
preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
} else {
preview = mainLabel;
}
return (
<p className="text-sm font-medium">{preview}</p>
);
})()}
</div>
)}
<Separator />
@ -846,24 +1029,67 @@ export function FieldDetailSettingsModal({
<div>
<Label className="text-[9px]"> ( )</Label>
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn || ""}
onValueChange={(value) =>
updateLinkedFieldMapping(index, { sourceColumn: value })
<Popover
open={sourceColumnOpenMap[index] || false}
onOpenChange={(open) =>
setSourceColumnOpenMap((prev) => ({ ...prev, [index]: open }))
}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceColumnOpenMap[index] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{mapping.sourceColumn
? (() => {
const selectedCol = sourceTableColumns.find(
(c) => c.name === mapping.sourceColumn
);
return selectedCol
? `${selectedCol.name} (${selectedCol.label})`
: mapping.sourceColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-7 text-[9px]" />
<CommandList>
<CommandEmpty className="py-2 text-[9px] text-center">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label}`}
onSelect={() => {
updateLinkedFieldMapping(index, { sourceColumn: col.name });
setSourceColumnOpenMap((prev) => ({ ...prev, [index]: false }));
}}
className="text-[9px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceColumn === col.name
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.label})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={mapping.sourceColumn || ""}

View File

@ -57,13 +57,34 @@ export function SaveSettingsModal({
const [mainTableSearchOpen, setMainTableSearchOpen] = useState(false);
const [subTableSearchOpen, setSubTableSearchOpen] = useState<Record<number, boolean>>({});
// 컬럼 검색 Popover 상태
const [mainKeyColumnSearchOpen, setMainKeyColumnSearchOpen] = useState(false);
const [mainFieldSearchOpen, setMainFieldSearchOpen] = useState<Record<number, boolean>>({});
const [subColumnSearchOpen, setSubColumnSearchOpen] = useState<Record<number, boolean>>({});
const [subTableColumnSearchOpen, setSubTableColumnSearchOpen] = useState<Record<string, boolean>>({});
const [markerColumnSearchOpen, setMarkerColumnSearchOpen] = useState<Record<number, boolean>>({});
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalSaveConfig(saveConfig);
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
// 모달이 열릴 때 기존에 설정된 테이블들의 컬럼 정보 로드
const mainTableName = saveConfig.customApiSave?.multiTable?.mainTable?.tableName;
if (mainTableName && !tableColumns[mainTableName]) {
onLoadTableColumns(mainTableName);
}
// 서브 테이블들의 컬럼 정보도 로드
const subTables = saveConfig.customApiSave?.multiTable?.subTables || [];
subTables.forEach((subTable) => {
if (subTable.tableName && !tableColumns[subTable.tableName]) {
onLoadTableColumns(subTable.tableName);
}
});
}
}, [open, saveConfig]);
}, [open, saveConfig, tableColumns, onLoadTableColumns]);
// 저장 설정 업데이트 함수
const updateSaveConfig = (updates: Partial<SaveConfig>) => {
@ -558,35 +579,76 @@ export function SaveSettingsModal({
<div>
<Label className="text-[10px]"> </Label>
{mainTableColumns.length > 0 ? (
<Select
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
onValueChange={(value) =>
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
primaryKeyColumn: value,
},
},
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{mainTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={mainKeyColumnSearchOpen} onOpenChange={setMainKeyColumnSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mainKeyColumnSearchOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn ? (
<>
{localSaveConfig.customApiSave.multiTable.mainTable.primaryKeyColumn}
{(() => {
const col = mainTableColumns.find(c => c.name === localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명 또는 라벨로 검색..." className="text-xs h-8" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-3 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{mainTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSaveConfig({
customApiSave: {
...localSaveConfig.customApiSave,
multiTable: {
...localSaveConfig.customApiSave?.multiTable,
mainTable: {
...localSaveConfig.customApiSave?.multiTable?.mainTable,
primaryKeyColumn: col.name,
},
},
},
});
setMainKeyColumnSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[10px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
@ -775,25 +837,70 @@ export function SaveSettingsModal({
<div>
<Label className="text-[9px]"> </Label>
{mainTableColumns.length > 0 ? (
<Select
value={subTable.linkColumn?.mainField || ""}
onValueChange={(value) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, mainField: value },
})
}
<Popover
open={mainFieldSearchOpen[subIndex] || false}
onOpenChange={(open) => setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{mainTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mainFieldSearchOpen[subIndex] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{subTable.linkColumn?.mainField ? (
<>
{subTable.linkColumn.mainField}
{(() => {
const col = mainTableColumns.find(c => c.name === subTable.linkColumn?.mainField);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{mainTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, mainField: col.name },
});
setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
subTable.linkColumn?.mainField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={subTable.linkColumn?.mainField || ""}
@ -811,25 +918,70 @@ export function SaveSettingsModal({
<div>
<Label className="text-[9px]"> </Label>
{subTableColumns.length > 0 ? (
<Select
value={subTable.linkColumn?.subColumn || ""}
onValueChange={(value) =>
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, subColumn: value },
})
}
<Popover
open={subColumnSearchOpen[subIndex] || false}
onOpenChange={(open) => setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
<SelectTrigger className="h-6 text-[9px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subColumnSearchOpen[subIndex] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{subTable.linkColumn?.subColumn ? (
<>
{subTable.linkColumn.subColumn}
{(() => {
const col = subTableColumns.find(c => c.name === subTable.linkColumn?.subColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{subTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSubTable(subIndex, {
linkColumn: { ...subTable.linkColumn, subColumn: col.name },
});
setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
subTable.linkColumn?.subColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={subTable.linkColumn?.subColumn || ""}
@ -911,23 +1063,68 @@ export function SaveSettingsModal({
<div>
<Label className="text-[8px]"> </Label>
{subTableColumns.length > 0 ? (
<Select
value={mapping.targetColumn || ""}
onValueChange={(value) =>
updateFieldMapping(subIndex, mapIndex, { targetColumn: value })
}
<Popover
open={subTableColumnSearchOpen[`${subIndex}-${mapIndex}`] || false}
onOpenChange={(open) => setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: open }))}
>
<SelectTrigger className="h-5 text-[8px] mt-0.5">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subTableColumnSearchOpen[`${subIndex}-${mapIndex}`] || false}
className="h-5 w-full justify-between text-[8px] mt-0.5 font-normal"
>
{mapping.targetColumn ? (
<>
{mapping.targetColumn}
{(() => {
const col = subTableColumns.find(c => c.name === mapping.targetColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{subTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateFieldMapping(subIndex, mapIndex, { targetColumn: col.name });
setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={mapping.targetColumn || ""}
@ -946,6 +1143,289 @@ export function SaveSettingsModal({
</div>
)}
</div>
<Separator />
{/* 대표 데이터 구분 저장 옵션 */}
<div className="space-y-2">
{!subTable.options?.saveMainAsFirst ? (
// 비활성화 상태: 추가 버튼 표시
<div className="border-2 border-dashed border-muted rounded-lg p-3 hover:border-primary/50 hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium text-muted-foreground">/ </p>
<p className="text-[9px] text-muted-foreground/70 mt-0.5">
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
saveMainAsFirst: true,
mainMarkerColumn: "",
mainMarkerValue: true,
subMarkerValue: false,
}
})}
className="h-6 text-[9px] px-2 shrink-0"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
) : (
// 활성화 상태: 설정 필드 표시
<div className="border rounded-lg p-3 bg-amber-50/50 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium">/ </p>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
saveMainAsFirst: false,
mainMarkerColumn: undefined,
mainMarkerValue: undefined,
subMarkerValue: undefined,
}
})}
className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-[9px]"> </Label>
{subTableColumns.length > 0 ? (
<Popover
open={markerColumnSearchOpen[subIndex] || false}
onOpenChange={(open) => setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={markerColumnSearchOpen[subIndex] || false}
className="h-6 w-full justify-between text-[9px] mt-0.5 font-normal"
>
{subTable.options?.mainMarkerColumn ? (
<>
{subTable.options.mainMarkerColumn}
{(() => {
const col = subTableColumns.find(c => c.name === subTable.options?.mainMarkerColumn);
return col?.label && col.label !== col.name ? ` (${col.label})` : "";
})()}
</>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼명/라벨 검색..." className="text-xs h-7" />
<CommandList className="max-h-[180px]">
<CommandEmpty className="py-2 text-center text-[10px]">
.
</CommandEmpty>
<CommandGroup>
{subTableColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.name} ${col.label || ""}`}
onSelect={() => {
updateSubTable(subIndex, {
options: {
...subTable.options,
mainMarkerColumn: col.name,
}
});
setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: false }));
}}
className="text-[10px]"
>
<Check
className={cn(
"mr-2 h-3 w-3",
subTable.options?.mainMarkerColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">{col.label}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={subTable.options?.mainMarkerColumn || ""}
onChange={(e) => updateSubTable(subIndex, {
options: {
...subTable.options,
mainMarkerColumn: e.target.value,
}
})}
placeholder="is_primary"
className="h-6 text-[9px] mt-0.5"
/>
)}
<HelpText>/ </HelpText>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px]"> ()</Label>
<Input
value={String(subTable.options?.mainMarkerValue ?? "true")}
onChange={(e) => {
const val = e.target.value;
// true/false 문자열은 boolean으로 변환
let parsedValue: any = val;
if (val === "true") parsedValue = true;
else if (val === "false") parsedValue = false;
else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
updateSubTable(subIndex, {
options: {
...subTable.options,
mainMarkerValue: parsedValue,
}
});
}}
placeholder="true, Y, 1 등"
className="h-6 text-[9px] mt-0.5"
/>
<HelpText> </HelpText>
</div>
<div>
<Label className="text-[9px]"> ()</Label>
<Input
value={String(subTable.options?.subMarkerValue ?? "false")}
onChange={(e) => {
const val = e.target.value;
let parsedValue: any = val;
if (val === "true") parsedValue = true;
else if (val === "false") parsedValue = false;
else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val);
updateSubTable(subIndex, {
options: {
...subTable.options,
subMarkerValue: parsedValue,
}
});
}}
placeholder="false, N, 0 등"
className="h-6 text-[9px] mt-0.5"
/>
<HelpText> </HelpText>
</div>
</div>
</div>
</div>
)}
</div>
<Separator />
{/* 수정 시 데이터 로드 옵션 */}
<div className="space-y-2">
{!subTable.options?.loadOnEdit ? (
// 비활성화 상태: 추가 버튼 표시
<div className="border-2 border-dashed border-muted rounded-lg p-3 hover:border-primary/50 hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<p className="text-[9px] text-muted-foreground/70 mt-0.5">
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
loadOnEdit: true,
loadOnlySubItems: true,
}
})}
className="h-6 text-[9px] px-2 shrink-0"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
) : (
// 활성화 상태: 설정 필드 표시
<div className="border rounded-lg p-3 bg-blue-50/50 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-medium"> </p>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateSubTable(subIndex, {
options: {
...subTable.options,
loadOnEdit: false,
loadOnlySubItems: undefined,
}
})}
className="h-6 text-[9px] px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="flex items-center gap-2">
<Switch
id={`loadOnlySubItems-${subIndex}`}
checked={subTable.options?.loadOnlySubItems ?? true}
onCheckedChange={(checked) => updateSubTable(subIndex, {
options: {
...subTable.options,
loadOnlySubItems: checked,
}
})}
/>
<Label htmlFor={`loadOnlySubItems-${subIndex}`} className="text-[9px]">
( )
</Label>
</div>
<HelpText>
,
</HelpText>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>

View File

@ -1125,3 +1125,4 @@ export function SectionLayoutModal({
}

View File

@ -699,6 +699,357 @@ export function TableColumnSettingsModal({
</Button>
</div>
</div>
{/* 동적 Select 옵션 (소스 테이블에서 로드) */}
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium"> ( )</h4>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
<Switch
checked={localColumn.dynamicSelectOptions?.enabled ?? false}
onCheckedChange={(checked) => {
updateColumn({
dynamicSelectOptions: checked
? {
enabled: true,
sourceField: "",
distinct: true,
}
: undefined,
});
}}
/>
</div>
{localColumn.dynamicSelectOptions?.enabled && (
<div className="space-y-3 pl-2 border-l-2 border-primary/20">
{/* 소스 필드 */}
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground mb-1">
</p>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.sourceField || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
sourceField: value,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} {col.comment && `(${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.sourceField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
sourceField: e.target.value,
},
});
}}
placeholder="inspection_item"
className="h-8 text-xs"
/>
)}
</div>
{/* 라벨 필드 */}
<div>
<Label className="text-xs"> ()</Label>
<p className="text-[10px] text-muted-foreground mb-1">
( )
</p>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.labelField || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
labelField: value || undefined,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="(소스 컬럼과 동일)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> ( )</SelectItem>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} {col.comment && `(${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.labelField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
labelField: e.target.value || undefined,
},
});
}}
placeholder="(비워두면 소스 컬럼 사용)"
className="h-8 text-xs"
/>
)}
</div>
{/* 행 선택 모드 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center gap-2">
<Switch
checked={localColumn.dynamicSelectOptions.rowSelectionMode?.enabled ?? false}
onCheckedChange={(checked) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: checked
? {
enabled: true,
autoFillMappings: [],
}
: undefined,
},
});
}}
className="scale-75"
/>
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{localColumn.dynamicSelectOptions.rowSelectionMode?.enabled && (
<div className="space-y-3 pl-4">
{/* 소스 ID 저장 설정 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> ID </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.rowSelectionMode.sourceIdColumn || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
sourceIdColumn: value || undefined,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="id" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.rowSelectionMode.sourceIdColumn || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
sourceIdColumn: e.target.value || undefined,
},
},
});
}}
placeholder="id"
className="h-7 text-xs mt-1"
/>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localColumn.dynamicSelectOptions.rowSelectionMode.targetIdField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
targetIdField: e.target.value || undefined,
},
},
});
}}
placeholder="inspection_standard_id"
className="h-7 text-xs mt-1"
/>
</div>
</div>
{/* 자동 채움 매핑 */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[10px]"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentMappings = localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [];
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: [...currentMappings, { sourceColumn: "", targetField: "" }],
},
},
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).map((mapping, idx) => (
<div key={idx} className="flex items-center gap-2">
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], sourceColumn: value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.sourceColumn}
onChange={(e) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], sourceColumn: e.target.value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
placeholder="소스 컬럼"
className="h-7 text-xs flex-1"
/>
)}
<span className="text-xs text-muted-foreground"></span>
<Input
value={mapping.targetField}
onChange={(e) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], targetField: e.target.value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
placeholder="타겟 필드"
className="h-7 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newMappings = (localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || []).filter((_, i) => i !== idx);
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).length === 0 && (
<p className="text-[10px] text-muted-foreground text-center py-2 border border-dashed rounded">
(: inspection_criteria inspection_standard)
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</>
)}
</TabsContent>

View File

@ -80,7 +80,8 @@ export interface FormFieldConfig {
linkedFieldGroup?: {
enabled?: boolean; // 사용 여부
sourceTable?: string; // 소스 테이블 (예: dept_info)
displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
displayColumn?: string; // 메인 표시 컬럼 (예: item_name) - 드롭다운에 보여줄 메인 텍스트
subDisplayColumn?: string; // 서브 표시 컬럼 (예: item_number) - 메인과 함께 표시될 서브 텍스트
displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식
// 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용)
// 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)")
@ -252,10 +253,19 @@ export interface TableSectionConfig {
// 6. UI 설정
uiConfig?: {
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
// 버튼 표시 설정 (동시 표시 가능)
showSearchButton?: boolean; // 검색 버튼 표시 (기본: true)
showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false)
searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색")
addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력")
// 레거시 호환용 (deprecated)
addButtonType?: "search" | "addRow";
addButtonText?: string;
};
// 7. 조건부 테이블 설정 (고급)
@ -295,6 +305,13 @@ export interface ConditionalTableConfig {
labelColumn: string; // 예: type_name
filterCondition?: string; // 예: is_active = 'Y'
};
// 소스 테이블 필터링 설정
// 조건 선택 시 소스 테이블(검사기준 등)에서 해당 조건으로 필터링
sourceFilter?: {
enabled: boolean;
filterColumn: string; // 소스 테이블에서 필터링할 컬럼 (예: inspection_type)
};
}
/**
@ -373,6 +390,30 @@ export interface TableColumnConfig {
// Select 옵션 (type이 "select"일 때)
selectOptions?: { value: string; label: string }[];
// 동적 Select 옵션 (소스 테이블에서 옵션 로드)
// 조건부 테이블의 sourceFilter가 활성화되어 있으면 자동으로 필터 적용
dynamicSelectOptions?: {
enabled: boolean;
sourceField: string; // 소스 테이블에서 가져올 컬럼 (예: inspection_item)
labelField?: string; // 표시 라벨 컬럼 (없으면 sourceField 사용)
distinct?: boolean; // 중복 제거 (기본: true)
// 행 선택 모드: 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움
// 활성화하면 이 컬럼이 "대표 컬럼"이 되어 선택 시 연관 컬럼들이 자동으로 채워짐
rowSelectionMode?: {
enabled: boolean;
// 자동 채움할 컬럼 매핑 (소스 컬럼 → 타겟 필드)
// 예: [{ sourceColumn: "inspection_criteria", targetField: "inspection_standard" }]
autoFillColumns?: {
sourceColumn: string; // 소스 테이블의 컬럼
targetField: string; // 현재 테이블의 필드
}[];
// 소스 테이블의 ID 컬럼 (참조 ID 저장용)
sourceIdColumn?: string; // 예: "id"
targetIdField?: string; // 예: "inspection_standard_id"
};
};
// 값 매핑 (핵심 기능) - 고급 설정용
valueMapping?: ValueMappingConfig;
@ -389,6 +430,31 @@ export interface TableColumnConfig {
// 부모에서 값 받기 (모든 행에 동일한 값 적용)
receiveFromParent?: boolean; // 부모에서 값 받기 활성화
parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일)
// 저장 설정 (컬럼별 저장 여부 및 참조 표시)
saveConfig?: TableColumnSaveConfig;
}
/**
*
* - , ID로
*/
export interface TableColumnSaveConfig {
// 저장 여부 (기본값: true)
// true: 사용자가 입력/선택한 값을 DB에 저장
// false: 저장하지 않고, 참조 ID로 소스 테이블을 조회하여 표시만 함
saveToTarget: boolean;
// 참조 표시 설정 (saveToTarget이 false일 때 사용)
referenceDisplay?: {
// 참조할 ID 컬럼 (같은 테이블 내의 다른 컬럼)
// 예: "inspection_standard_id"
referenceIdField: string;
// 소스 테이블에서 가져올 컬럼
// 예: "inspection_item" → 소스 테이블의 inspection_item 값을 표시
sourceColumn: string;
};
}
// ============================================
@ -642,6 +708,10 @@ export interface SubTableSaveConfig {
// 저장 전 기존 데이터 삭제
deleteExistingBefore?: boolean;
deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제
// 수정 모드에서 서브 테이블 데이터 로드
loadOnEdit?: boolean; // 수정 시 서브 테이블 데이터 로드 여부
loadOnlySubItems?: boolean; // 서브 항목만 로드 (메인 항목 제외)
};
}

View File

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