From c32bd8a4bf4ba572260ff9be9f3c527d76e3912d Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 10:48:11 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B7=9C=EC=B9=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/table-type-sql-guide.mdc | 368 +++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 .cursor/rules/table-type-sql-guide.mdc diff --git a/.cursor/rules/table-type-sql-guide.mdc b/.cursor/rules/table-type-sql-guide.mdc new file mode 100644 index 00000000..501c3218 --- /dev/null +++ b/.cursor/rules/table-type-sql-guide.mdc @@ -0,0 +1,368 @@ +# 테이블 타입 관리 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. 체크리스트 + +테이블 생성/수정 시 반드시 확인할 사항: + +- [ ] 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 처리 + +--- + +## 8. 금지 사항 + +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/types/ddl.ts`: DDL 타입 정의 +- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러 From 06d5069566ee9cde0c85adbe2047ae1aba5bda32 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 10:54:06 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B4=80=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/table-type-sql-guide.mdc | 230 ++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/table-type-sql-guide.mdc b/.cursor/rules/table-type-sql-guide.mdc index 501c3218..3c53c537 100644 --- a/.cursor/rules/table-type-sql-guide.mdc +++ b/.cursor/rules/table-type-sql-guide.mdc @@ -335,9 +335,222 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu --- -## 7. 체크리스트 +## 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)` 타입 사용 @@ -349,9 +562,18 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu - [ ] 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'` 업데이트 + --- -## 8. 금지 사항 +## 9. 금지 사항 1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지 2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용 @@ -364,5 +586,7 @@ ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.colu ## 참조 파일 - `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 컨트롤러 From 3c4e251e9b2cc6aebe166c134c8cf1f2fd47f322 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 12:33:17 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=ED=8F=BC=20=EB=8B=A4=EC=A4=91=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A0=80=EC=9E=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalComponent.tsx | 82 +++-- frontend/lib/utils/buttonActions.ts | 309 +++++++++++++----- 2 files changed, 284 insertions(+), 107 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index b4921a51..9edf4054 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -963,6 +963,13 @@ export function UniversalFormModalComponent({ } } + // 별도 테이블에 저장해야 하는 테이블 섹션 목록 + const tableSectionsForSeparateTable = config.sections.filter( + (s) => s.type === "table" && + s.tableConfig?.saveConfig?.targetTable && + s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName + ); + // 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장) // targetTable이 없거나 메인 테이블과 같은 경우 const tableSectionsForMainTable = config.sections.filter( @@ -971,6 +978,12 @@ export function UniversalFormModalComponent({ s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName) ); + console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName); + console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id)); + console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id)); + console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData)); + console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave)); + if (tableSectionsForMainTable.length > 0) { // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) const commonFieldsData: Record = {}; @@ -1050,35 +1063,51 @@ export function UniversalFormModalComponent({ // 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기) const mainRecordId = response.data?.data?.id; - // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) + // 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값 + // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual' const commonFieldsData: Record = {}; const { sectionSaveModes } = config.saveConfig; - if (sectionSaveModes && sectionSaveModes.length > 0) { - // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 - for (const otherSection of config.sections) { - if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 - - const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id); - const defaultMode = otherSection.type === "table" ? "individual" : "common"; - const sectionSaveMode = sectionMode?.saveMode || defaultMode; - - // 필드 타입 섹션의 필드들 처리 - if (otherSection.type !== "table" && otherSection.fields) { - for (const field of otherSection.fields) { - // 필드별 오버라이드 확인 - const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); - const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; - - // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용 - if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { - commonFieldsData[field.columnName] = formData[field.columnName]; + // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 + for (const otherSection of config.sections) { + if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 + + const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id); + // 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual' + const defaultMode = otherSection.type === "table" ? "individual" : "common"; + const sectionSaveMode = sectionMode?.saveMode || defaultMode; + + // 필드 타입 섹션의 필드들 처리 + if (otherSection.type !== "table" && otherSection.fields) { + for (const field of otherSection.fields) { + // 필드별 오버라이드 확인 + const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); + const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; + + // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용 + if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { + commonFieldsData[field.columnName] = formData[field.columnName]; + } + } + } + + // 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리 + if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) { + for (const optGroup of otherSection.optionalFieldGroups) { + if (optGroup.fields) { + for (const field of optGroup.fields) { + // 선택적 필드 그룹은 기본적으로 common 저장 + if (formData[field.columnName] !== undefined) { + commonFieldsData[field.columnName] = formData[field.columnName]; + } } } } } } + console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData)); + for (const item of sectionData) { // 공통 필드 병합 + 개별 품목 데이터 const itemToSave = { ...commonFieldsData, ...item }; @@ -1091,15 +1120,26 @@ export function UniversalFormModalComponent({ } } + // _sourceData 등 내부 메타데이터 제거 + Object.keys(itemToSave).forEach((key) => { + if (key.startsWith("_")) { + delete itemToSave[key]; + } + }); + // 메인 레코드와 연결이 필요한 경우 if (mainRecordId && config.saveConfig.primaryKeyColumn) { itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; } - await apiClient.post( + const saveResponse = await apiClient.post( `/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`, itemToSave ); + + if (!saveResponse.data?.success) { + throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`); + } } } } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index de98028a..9a6a606e 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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 = {}; const commonFieldsData: Record = {}; @@ -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 { + private static async handleOpenRelatedModal( + config: ButtonActionConfig, + context: ButtonActionContext, + ): Promise { // 버튼 설정에서 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 = {}; - + 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 { + public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { 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 = {}; 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("📍 데이터 새로고침 이벤트 발송"); // 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림 From fd58e9cce2ce64d145ffae72b5af404c9600a195 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 13:32:49 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=ED=96=89=EC=B6=94=EA=B0=80,=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=8F=99=EC=8B=9C=EC=9E=85=EB=A0=A5=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableSectionRenderer.tsx | 1099 +++++++++-------- .../modals/TableSectionSettingsModal.tsx | 91 +- .../components/universal-form-modal/types.ts | 12 +- 3 files changed, 639 insertions(+), 563 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index ba03d2b9..a1c0bd76 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -16,12 +16,7 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal"; import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types"; // 타입 정의 -import { - TableSectionConfig, - TableColumnConfig, - TableJoinCondition, - FormDataState, -} from "./types"; +import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types"; interface TableSectionRendererProps { sectionId: string; @@ -56,7 +51,7 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { selectOptions: col.selectOptions, // valueMapping은 별도로 처리 }; - + // lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능) if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) { baseColumn.dynamicDataSource = { @@ -75,17 +70,19 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { sourceField: cond.sourceField, targetField: cond.targetColumn, // sourceType에 따른 데이터 출처 설정 - sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable" + sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable" fromFormData: cond.sourceType === "sectionField", sectionId: cond.sectionId, // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) - transform: cond.transform?.enabled ? { - tableName: cond.transform.tableName, - matchColumn: cond.transform.matchColumn, - resultColumn: cond.transform.resultColumn, - } : undefined, + transform: cond.transform?.enabled + ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } + : undefined, })), }, // 조회 유형 정보 추가 @@ -115,14 +112,18 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id, }; } - + return baseColumn; } /** * TableCalculationRule을 CalculationRule로 변환 */ -function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule { +function convertToCalculationRule(calc: { + resultField: string; + formula: string; + dependencies: string[]; +}): CalculationRule { return { result: calc.resultField, formula: calc.formula, @@ -136,7 +137,7 @@ function convertToCalculationRule(calc: { resultField: string; formula: string; */ async function transformValue( value: any, - transform: { tableName: string; matchColumn: string; resultColumn: string } + transform: { tableName: string; matchColumn: string; resultColumn: string }, ): Promise { if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) { return value; @@ -144,19 +145,16 @@ async function transformValue( try { // 정확히 일치하는 검색 - const response = await apiClient.post( - `/table-management/tables/${transform.tableName}/data`, - { - search: { - [transform.matchColumn]: { - value: value, - operator: "equals" - } - }, - size: 1, - page: 1 - } - ); + const response = await apiClient.post(`/table-management/tables/${transform.tableName}/data`, { + search: { + [transform.matchColumn]: { + value: value, + operator: "equals", + }, + }, + size: 1, + page: 1, + }); if (response.data.success && response.data.data?.data?.length > 0) { const transformedValue = response.data.data.data[0][transform.resultColumn]; @@ -186,7 +184,7 @@ async function fetchExternalLookupValue( }, rowData: any, sourceData: any, - formData: FormDataState + formData: FormDataState, ): Promise { // 1. 비교 값 가져오기 let matchValue: any; @@ -199,31 +197,32 @@ async function fetchExternalLookupValue( } if (matchValue === undefined || matchValue === null || matchValue === "") { - console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`); + console.warn( + `외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`, + ); return undefined; } // 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색) try { - const response = await apiClient.post( - `/table-management/tables/${externalLookup.tableName}/data`, - { - search: { - [externalLookup.matchColumn]: { - value: matchValue, - operator: "equals" - } - }, - size: 1, - page: 1 - } - ); + const response = await apiClient.post(`/table-management/tables/${externalLookup.tableName}/data`, { + search: { + [externalLookup.matchColumn]: { + value: matchValue, + operator: "equals", + }, + }, + size: 1, + page: 1, + }); if (response.data.success && response.data.data?.data?.length > 0) { return response.data.data.data[0][externalLookup.resultColumn]; } - console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`); + console.warn( + `외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`, + ); return undefined; } catch (error) { console.error("외부 테이블 조회 오류:", error); @@ -233,7 +232,7 @@ async function fetchExternalLookupValue( /** * 외부 테이블에서 값을 조회하는 함수 - * + * * @param tableName - 조회할 테이블명 * @param valueColumn - 가져올 컬럼명 * @param joinConditions - 조인 조건 목록 @@ -247,7 +246,7 @@ async function fetchExternalValue( joinConditions: TableJoinCondition[], rowData: any, sourceData: any, - formData: FormDataState + formData: FormDataState, ): Promise { if (joinConditions.length === 0) { return undefined; @@ -298,15 +297,16 @@ async function fetchExternalValue( // 정확히 일치하는 검색을 위해 operator: "equals" 사용 whereConditions[condition.targetColumn] = { value: convertedValue, - operator: "equals" + operator: "equals", }; } // API 호출 - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { search: whereConditions, size: 1, page: 1 } - ); + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + search: whereConditions, + size: 1, + page: 1, + }); if (response.data.success && response.data.data?.data?.length > 0) { return response.data.data.data[0][valueColumn]; @@ -334,42 +334,42 @@ export function TableSectionRenderer({ }: TableSectionRendererProps) { // 테이블 데이터 상태 (일반 모드) const [tableData, setTableData] = useState([]); - + // 조건부 테이블 데이터 상태 (조건별로 분리) const [conditionalTableData, setConditionalTableData] = useState({}); - + // 조건부 테이블: 선택된 조건들 (체크박스 모드) const [selectedConditions, setSelectedConditions] = useState([]); - + // 조건부 테이블: 현재 활성 탭 const [activeConditionTab, setActiveConditionTab] = useState(""); - + // 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지) const [modalCondition, setModalCondition] = useState(""); - + // 모달 상태 const [modalOpen, setModalOpen] = useState(false); - + // 체크박스 선택 상태 (조건별로 분리) const [selectedRows, setSelectedRows] = useState>(new Set()); const [conditionalSelectedRows, setConditionalSelectedRows] = useState>>({}); - + // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) const [widthTrigger, setWidthTrigger] = useState(0); - + // 동적 데이터 소스 활성화 상태 const [activeDataSources, setActiveDataSources] = useState>({}); - + // 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용) const [batchAppliedFields, setBatchAppliedFields] = useState>(new Set()); // 초기 데이터 로드 완료 플래그 (무한 루프 방지) const initialDataLoadedRef = React.useRef(false); - + // 조건부 테이블 설정 const conditionalConfig = tableConfig.conditionalTable; const isConditionalMode = conditionalConfig?.enabled ?? false; - + // 조건부 테이블: 동적 옵션 로드 상태 const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]); const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false); @@ -380,51 +380,48 @@ export function TableSectionRenderer({ if (!isConditionalMode) return; if (!conditionalConfig?.optionSource?.enabled) return; if (dynamicOptionsLoadedRef.current) return; - + const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource; - + if (!tableName || !valueColumn) return; - + const loadDynamicOptions = async () => { setDynamicOptionsLoading(true); try { // DISTINCT 값을 가져오기 위한 API 호출 - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { - search: filterCondition ? { _raw: filterCondition } : {}, - size: 1000, - page: 1, - } - ); - + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + search: filterCondition ? { _raw: filterCondition } : {}, + size: 1000, + page: 1, + }); + if (response.data.success && response.data.data?.data) { const rows = response.data.data.data; - + // 중복 제거하여 고유 값 추출 const uniqueValues = new Map(); for (const row of rows) { const value = row[valueColumn]; if (value && !uniqueValues.has(value)) { - const label = labelColumn ? (row[labelColumn] || value) : value; + const label = labelColumn ? row[labelColumn] || value : value; uniqueValues.set(value, label); } } - + // 옵션 배열로 변환 const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({ id: `dynamic_${index}`, value, label, })); - + console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { tableName, valueColumn, optionCount: options.length, options, }); - + setDynamicOptions(options); dynamicOptionsLoadedRef.current = true; } @@ -434,48 +431,45 @@ export function TableSectionRenderer({ setDynamicOptionsLoading(false); } }; - + loadDynamicOptions(); }, [isConditionalMode, conditionalConfig?.optionSource]); // ============================================ // 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드) // ============================================ - + // 소스 테이블 데이터 캐시 (동적 Select 옵션용) const [sourceDataCache, setSourceDataCache] = useState([]); const sourceDataLoadedRef = React.useRef(false); - + // 동적 Select 옵션이 있는 컬럼 확인 const hasDynamicSelectColumns = useMemo(() => { - return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled); + return tableConfig.columns?.some((col) => col.dynamicSelectOptions?.enabled); }, [tableConfig.columns]); - + // 소스 테이블 데이터 로드 (동적 Select 옵션용) useEffect(() => { if (!hasDynamicSelectColumns) return; if (sourceDataLoadedRef.current) return; if (!tableConfig.source?.tableName) return; - + const loadSourceData = async () => { try { // 조건부 테이블 필터 조건 적용 const filterCondition: Record = {}; - + // 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용 if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) { filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab; } - - const response = await apiClient.post( - `/table-management/tables/${tableConfig.source.tableName}/data`, - { - search: filterCondition, - size: 1000, - page: 1, - } - ); - + + const response = await apiClient.post(`/table-management/tables/${tableConfig.source.tableName}/data`, { + search: filterCondition, + size: 1000, + page: 1, + }); + if (response.data.success && response.data.data?.data) { setSourceDataCache(response.data.data.data); sourceDataLoadedRef.current = true; @@ -489,36 +483,33 @@ export function TableSectionRenderer({ console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error); } }; - + loadSourceData(); }, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]); - + // 조건 탭 변경 시 소스 데이터 다시 로드 useEffect(() => { if (!hasDynamicSelectColumns) return; if (!conditionalConfig?.sourceFilter?.enabled) return; if (!activeConditionTab) return; if (!tableConfig.source?.tableName) return; - + // 조건 변경 시 캐시 리셋하고 즉시 다시 로드 sourceDataLoadedRef.current = false; setSourceDataCache([]); - + // 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출) const loadSourceData = async () => { try { const filterCondition: Record = {}; filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab; - - const response = await apiClient.post( - `/table-management/tables/${tableConfig.source!.tableName}/data`, - { - search: filterCondition, - size: 1000, - page: 1, - } - ); - + + const response = await apiClient.post(`/table-management/tables/${tableConfig.source!.tableName}/data`, { + search: filterCondition, + size: 1000, + page: 1, + }); + if (response.data.success && response.data.data?.data) { setSourceDataCache(response.data.data.data); sourceDataLoadedRef.current = true; @@ -532,96 +523,100 @@ export function TableSectionRenderer({ console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error); } }; - + loadSourceData(); - }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]); - + }, [ + activeConditionTab, + hasDynamicSelectColumns, + conditionalConfig?.sourceFilter?.enabled, + conditionalConfig?.sourceFilter?.filterColumn, + tableConfig.source?.tableName, + ]); + // 컬럼별 동적 Select 옵션 생성 const dynamicSelectOptionsMap = useMemo(() => { const optionsMap: Record = {}; - + if (!sourceDataCache.length) return optionsMap; - + for (const col of tableConfig.columns || []) { if (!col.dynamicSelectOptions?.enabled) continue; - + const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions; - + if (!sourceField) continue; - + // 소스 데이터에서 옵션 추출 const seenValues = new Set(); const options: { value: string; label: string }[] = []; - + for (const row of sourceDataCache) { const value = row[sourceField]; if (value === undefined || value === null || value === "") continue; - + const stringValue = String(value); - + if (distinct && seenValues.has(stringValue)) continue; seenValues.add(stringValue); - - const label = labelField ? (row[labelField] || stringValue) : stringValue; + + const label = labelField ? row[labelField] || stringValue : stringValue; options.push({ value: stringValue, label: String(label) }); } - + optionsMap[col.field] = options; } - + return optionsMap; }, [sourceDataCache, tableConfig.columns]); - + // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - 다른 함수에서 참조하므로 먼저 정의 const handleDataChange = useCallback( (newData: any[]) => { let processedData = newData; - + // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 - const batchApplyColumns = tableConfig.columns.filter( - (col) => col.type === "date" && col.batchApply === true - ); - + const batchApplyColumns = tableConfig.columns.filter((col) => col.type === "date" && col.batchApply === true); + for (const dateCol of batchApplyColumns) { // 이미 일괄 적용된 컬럼은 건너뜀 if (batchAppliedFields.has(dateCol.field)) continue; - + // 해당 컬럼에 값이 있는 행과 없는 행 분류 const itemsWithDate = processedData.filter((item) => item[dateCol.field]); const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]); - + // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { const selectedDate = itemsWithDate[0][dateCol.field]; - + // 모든 행에 동일한 날짜 적용 processedData = processedData.map((item) => ({ ...item, [dateCol.field]: selectedDate, })); - + // 플래그 활성화 (이후 개별 수정 가능) setBatchAppliedFields((prev) => new Set([...prev, dateCol.field])); } } - + setTableData(processedData); onTableDataChange(processedData); }, - [onTableDataChange, tableConfig.columns, batchAppliedFields] + [onTableDataChange, tableConfig.columns, batchAppliedFields], ); - + // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움 const handleDynamicSelectChange = useCallback( (rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => { - const column = tableConfig.columns?.find(col => col.field === columnField); + const column = tableConfig.columns?.find((col) => col.field === columnField); if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { // 행 선택 모드가 아니면 일반 값 변경만 if (conditionValue && isConditionalMode) { const currentData = conditionalTableData[conditionValue] || []; const newData = [...currentData]; newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; - setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData })); onConditionalTableDataChange?.(conditionValue, newData); } else { const newData = [...tableData]; @@ -630,18 +625,18 @@ export function TableSectionRenderer({ } return; } - + // 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기 const { sourceField } = column.dynamicSelectOptions; const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode; - - const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue); - + + const sourceRow = sourceDataCache.find((row) => String(row[sourceField]) === selectedValue); + if (!sourceRow) { console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`); return; } - + // 현재 행 데이터 가져오기 let currentData: any[]; if (conditionValue && isConditionalMode) { @@ -649,10 +644,10 @@ export function TableSectionRenderer({ } else { currentData = tableData; } - + const newData = [...currentData]; const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue }; - + // 자동 채움 매핑 적용 if (autoFillColumns) { for (const mapping of autoFillColumns) { @@ -662,22 +657,22 @@ export function TableSectionRenderer({ } } } - + // 소스 ID 저장 if (sourceIdColumn && targetIdField) { updatedRow[targetIdField] = sourceRow[sourceIdColumn]; } - + newData[rowIndex] = updatedRow; - + // 데이터 업데이트 if (conditionValue && isConditionalMode) { - setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData })); onConditionalTableDataChange?.(conditionValue, newData); } else { handleDataChange(newData); } - + console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", { columnField, selectedValue, @@ -685,93 +680,101 @@ export function TableSectionRenderer({ updatedRow, }); }, - [tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange] + [ + tableConfig.columns, + sourceDataCache, + tableData, + conditionalTableData, + isConditionalMode, + handleDataChange, + onConditionalTableDataChange, + ], ); // 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회) - const loadReferenceColumnValues = useCallback(async (data: any[]) => { - // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기 - const referenceColumns = (tableConfig.columns || []).filter( - (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay - ); - - if (referenceColumns.length === 0) return; - - const sourceTableName = tableConfig.source?.tableName; - if (!sourceTableName) { - console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다."); - return; - } - - // 참조 ID들 수집 (중복 제거) - const referenceIdSet = new Set(); - - for (const col of referenceColumns) { - const refDisplay = col.saveConfig!.referenceDisplay!; - - for (const row of data) { - const refId = row[refDisplay.referenceIdField]; - if (refId !== undefined && refId !== null && refId !== "") { - referenceIdSet.add(String(refId)); + const loadReferenceColumnValues = useCallback( + async (data: any[]) => { + // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기 + const referenceColumns = (tableConfig.columns || []).filter( + (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay, + ); + + if (referenceColumns.length === 0) return; + + const sourceTableName = tableConfig.source?.tableName; + if (!sourceTableName) { + console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다."); + return; + } + + // 참조 ID들 수집 (중복 제거) + const referenceIdSet = new Set(); + + for (const col of referenceColumns) { + const refDisplay = col.saveConfig!.referenceDisplay!; + + for (const row of data) { + const refId = row[refDisplay.referenceIdField]; + if (refId !== undefined && refId !== null && refId !== "") { + referenceIdSet.add(String(refId)); + } } } - } - - if (referenceIdSet.size === 0) return; - - try { - // 소스 테이블에서 참조 ID에 해당하는 데이터 조회 - const response = await apiClient.post( - `/table-management/tables/${sourceTableName}/data`, - { + + if (referenceIdSet.size === 0) return; + + try { + // 소스 테이블에서 참조 ID에 해당하는 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${sourceTableName}/data`, { search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회 size: 1000, page: 1, + }); + + if (!response.data?.success || !response.data?.data?.data) { + console.warn("[TableSectionRenderer] 참조 데이터 조회 실패"); + return; } - ); - - if (!response.data?.success || !response.data?.data?.data) { - console.warn("[TableSectionRenderer] 참조 데이터 조회 실패"); - return; - } - - const sourceData: any[] = response.data.data.data; - - // ID를 키로 하는 맵 생성 - const sourceDataMap: Record = {}; - for (const sourceRow of sourceData) { - sourceDataMap[String(sourceRow.id)] = sourceRow; - } - - // 각 행에 참조 컬럼 값 채우기 - const updatedData = data.map((row) => { - const newRow = { ...row }; - - for (const col of referenceColumns) { - const refDisplay = col.saveConfig!.referenceDisplay!; - const refId = row[refDisplay.referenceIdField]; - - if (refId !== undefined && refId !== null && refId !== "") { - const sourceRow = sourceDataMap[String(refId)]; - if (sourceRow) { - newRow[col.field] = sourceRow[refDisplay.sourceColumn]; + + const sourceData: any[] = response.data.data.data; + + // ID를 키로 하는 맵 생성 + const sourceDataMap: Record = {}; + for (const sourceRow of sourceData) { + sourceDataMap[String(sourceRow.id)] = sourceRow; + } + + // 각 행에 참조 컬럼 값 채우기 + const updatedData = data.map((row) => { + const newRow = { ...row }; + + for (const col of referenceColumns) { + const refDisplay = col.saveConfig!.referenceDisplay!; + const refId = row[refDisplay.referenceIdField]; + + if (refId !== undefined && refId !== null && refId !== "") { + const sourceRow = sourceDataMap[String(refId)]; + if (sourceRow) { + newRow[col.field] = sourceRow[refDisplay.sourceColumn]; + } } } - } - - return newRow; - }); - - console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", { - referenceColumns: referenceColumns.map((c) => c.field), - updatedRowCount: updatedData.length, - }); - - setTableData(updatedData); - } catch (error) { - console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error); - } - }, [tableConfig.columns, tableConfig.source?.tableName]); + + return newRow; + }); + + console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", { + referenceColumns: referenceColumns.map((c) => c.field), + updatedRowCount: updatedData.length, + }); + + setTableData(updatedData); + } catch (error) { + console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error); + } + }, + [tableConfig.columns, tableConfig.source?.tableName], + ); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) useEffect(() => { @@ -788,7 +791,7 @@ export function TableSectionRenderer({ }); setTableData(initialData); initialDataLoadedRef.current = true; - + // 참조 컬럼 값 조회 (saveToTarget: false인 컬럼) loadReferenceColumnValues(initialData); } @@ -796,14 +799,14 @@ export function TableSectionRenderer({ // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) const columns: RepeaterColumnConfig[] = useMemo(() => { - return (tableConfig.columns || []).map(col => { + return (tableConfig.columns || []).map((col) => { const baseColumn = convertToRepeaterColumn(col); - + // 동적 Select 옵션이 있으면 적용 if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) { baseColumn.selectOptions = dynamicSelectOptionsMap[col.field]; } - + return baseColumn; }); }, [tableConfig.columns, dynamicSelectOptionsMap]); @@ -840,23 +843,24 @@ export function TableSectionRenderer({ return updatedRow; }, - [calculationRules] + [calculationRules], ); const calculateAll = useCallback( (data: any[]): any[] => { return data.map((row) => calculateRow(row)); }, - [calculateRow] + [calculateRow], ); // 행 변경 핸들러 (동적 Select 행 선택 모드 지원) const handleRowChange = useCallback( (index: number, newRow: any, conditionValue?: string) => { - const oldRow = conditionValue && isConditionalMode - ? (conditionalTableData[conditionValue]?.[index] || {}) - : (tableData[index] || {}); - + const oldRow = + conditionValue && isConditionalMode + ? conditionalTableData[conditionValue]?.[index] || {} + : tableData[index] || {}; + // 변경된 필드 찾기 const changedFields: string[] = []; for (const key of Object.keys(newRow)) { @@ -864,25 +868,25 @@ export function TableSectionRenderer({ changedFields.push(key); } } - + // 동적 Select 컬럼의 행 선택 모드 확인 for (const changedField of changedFields) { - const column = tableConfig.columns?.find(col => col.field === changedField); + const column = tableConfig.columns?.find((col) => col.field === changedField); if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { // 행 선택 모드 처리 (자동 채움) handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue); return; // 행 선택 모드에서 처리 완료 } } - + // 일반 행 변경 처리 const calculatedRow = calculateRow(newRow); - + if (conditionValue && isConditionalMode) { const currentData = conditionalTableData[conditionValue] || []; const newData = [...currentData]; newData[index] = calculatedRow; - setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData })); onConditionalTableDataChange?.(conditionValue, newData); } else { const newData = [...tableData]; @@ -890,7 +894,16 @@ export function TableSectionRenderer({ handleDataChange(newData); } }, - [tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange] + [ + tableData, + conditionalTableData, + isConditionalMode, + tableConfig.columns, + calculateRow, + handleDataChange, + handleDynamicSelectChange, + onConditionalTableDataChange, + ], ); // 행 삭제 핸들러 @@ -899,7 +912,7 @@ export function TableSectionRenderer({ const newData = tableData.filter((_, i) => i !== index); handleDataChange(newData); }, - [tableData, handleDataChange] + [tableData, handleDataChange], ); // 선택된 항목 일괄 삭제 @@ -908,7 +921,7 @@ export function TableSectionRenderer({ const newData = tableData.filter((_, index) => !selectedRows.has(index)); handleDataChange(newData); setSelectedRows(new Set()); - + // 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋 if (newData.length === 0) { setBatchAppliedFields(new Set()); @@ -931,7 +944,7 @@ export function TableSectionRenderer({ // 현재 활성화된 옵션 또는 기본 옵션 사용 const activeOptionId = activeDataSources[col.field]; const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0]; - const selectedOption = activeOptionId + const selectedOption = activeOptionId ? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption : defaultOption; @@ -969,11 +982,13 @@ export function TableSectionRenderer({ // 외부 테이블 조회 설정 externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) - transform: cond.transform?.enabled ? { - tableName: cond.transform.tableName, - matchColumn: cond.transform.matchColumn, - resultColumn: cond.transform.resultColumn, - } : undefined, + transform: cond.transform?.enabled + ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } + : undefined, }; }); @@ -982,15 +997,15 @@ export function TableSectionRenderer({ selectedOption.tableName, selectedOption.valueColumn, joinConditions, - { ...sourceItem, ...newItem }, // rowData (현재 행) - sourceItem, // sourceData (소스 테이블 원본) - formData + { ...sourceItem, ...newItem }, // rowData (현재 행) + sourceItem, // sourceData (소스 테이블 원본) + formData, ); - + if (value !== undefined) { newItem[col.field] = value; } - + // _sourceData에 원본 저장 newItem._sourceData = sourceItem; } @@ -1045,8 +1060,8 @@ export function TableSectionRenderer({ valueColumn, joinConditions, { ...sourceItem, ...newItem }, // rowData - sourceItem, // sourceData - formData + sourceItem, // sourceData + formData, ); if (value !== undefined) { newItem[col.field] = value; @@ -1070,7 +1085,7 @@ export function TableSectionRenderer({ } return newItem; - }) + }), ); // 계산 필드 업데이트 @@ -1080,7 +1095,7 @@ export function TableSectionRenderer({ const newData = [...tableData, ...calculatedItems]; handleDataChange(newData); }, - [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources] + [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources], ); // 컬럼 모드/조회 옵션 변경 핸들러 @@ -1093,7 +1108,7 @@ export function TableSectionRenderer({ // 해당 컬럼의 모든 행 데이터 재조회 const column = tableConfig.columns.find((col) => col.field === columnField); - + // lookup 설정이 있는 경우 (새로운 조회 기능) if (column?.lookup?.enabled && column.lookup.options) { const selectedOption = column.lookup.options.find((opt) => opt.id === optionId); @@ -1140,11 +1155,13 @@ export function TableSectionRenderer({ // 외부 테이블 조회 설정 externalLookup: cond.externalLookup, // 값 변환 설정 전달 (레거시 호환) - transform: cond.transform?.enabled ? { - tableName: cond.transform.tableName, - matchColumn: cond.transform.matchColumn, - resultColumn: cond.transform.resultColumn, - } : undefined, + transform: cond.transform?.enabled + ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } + : undefined, }; }); @@ -1156,15 +1173,15 @@ export function TableSectionRenderer({ joinConditions, row, sourceData, - formData + formData, ); - + if (value !== undefined) { newValue = value; } return { ...row, [columnField]: newValue }; - }) + }), ); // 계산 필드 업데이트 @@ -1199,14 +1216,14 @@ export function TableSectionRenderer({ } return { ...row, [columnField]: newValue }; - }) + }), ); // 계산 필드 업데이트 const calculatedData = calculateAll(updatedData); handleDataChange(calculatedData); }, - [tableConfig.columns, tableData, formData, calculateAll, handleDataChange] + [tableConfig.columns, tableData, formData, calculateAll, handleDataChange], ); // 소스 테이블 정보 @@ -1216,10 +1233,16 @@ export function TableSectionRenderer({ const sourceSearchFields = source.searchColumns; const columnLabels = source.columnLabels || {}; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; - const addButtonType = uiConfig?.addButtonType || "search"; - const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색"); const multiSelect = uiConfig?.multiSelect ?? true; + // 버튼 표시 설정 (두 버튼 동시 표시 가능) + // 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환 + const legacyAddButtonType = uiConfig?.addButtonType; + const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true); + const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false); + const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색"; + const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력"; + // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) const baseFilterCondition: Record = useMemo(() => { const condition: Record = {}; @@ -1233,19 +1256,19 @@ export function TableSectionRenderer({ } return condition; }, [filters?.preFilters]); - + // 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링) const conditionalFilterCondition = useMemo(() => { const filter = { ...baseFilterCondition }; - + // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; } - + return filter; }, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]); - + // 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환 const modalFiltersForModal = useMemo(() => { if (!filters?.modalFilters) return []; @@ -1253,7 +1276,7 @@ export function TableSectionRenderer({ column: filter.column, label: filter.label || filter.column, // category 타입을 select로 변환 (ModalFilterConfig 호환) - type: filter.type === "category" ? "select" as const : filter.type as "text" | "select", + type: filter.type === "category" ? ("select" as const) : (filter.type as "text" | "select"), options: filter.options, categoryRef: filter.categoryRef, defaultValue: filter.defaultValue, @@ -1265,138 +1288,156 @@ export function TableSectionRenderer({ // ============================================ // 조건부 테이블: 조건 체크박스 토글 - const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => { - setSelectedConditions((prev) => { - if (checked) { - const newConditions = [...prev, conditionValue]; - // 첫 번째 조건 선택 시 해당 탭 활성화 - if (prev.length === 0) { - setActiveConditionTab(conditionValue); + const handleConditionToggle = useCallback( + (conditionValue: string, checked: boolean) => { + setSelectedConditions((prev) => { + if (checked) { + const newConditions = [...prev, conditionValue]; + // 첫 번째 조건 선택 시 해당 탭 활성화 + if (prev.length === 0) { + setActiveConditionTab(conditionValue); + } + return newConditions; + } else { + const newConditions = prev.filter((c) => c !== conditionValue); + // 현재 활성 탭이 제거된 경우 다른 탭으로 전환 + if (activeConditionTab === conditionValue && newConditions.length > 0) { + setActiveConditionTab(newConditions[0]); + } + return newConditions; } - return newConditions; - } else { - const newConditions = prev.filter((c) => c !== conditionValue); - // 현재 활성 탭이 제거된 경우 다른 탭으로 전환 - if (activeConditionTab === conditionValue && newConditions.length > 0) { - setActiveConditionTab(newConditions[0]); - } - return newConditions; - } - }); - }, [activeConditionTab]); + }); + }, + [activeConditionTab], + ); // 조건부 테이블: 조건별 데이터 변경 - const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => { - setConditionalTableData((prev) => ({ - ...prev, - [conditionValue]: newData, - })); - - // 부모에게 조건별 데이터 변경 알림 - if (onConditionalTableDataChange) { - onConditionalTableDataChange(conditionValue, newData); - } - - // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출 - // (저장 시 조건 컬럼 값이 자동으로 추가됨) - const conditionColumn = conditionalConfig?.conditionColumn; - const allData: any[] = []; - - // 현재 변경된 조건의 데이터 업데이트 - const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData }; - - for (const [condition, data] of Object.entries(updatedConditionalData)) { - for (const row of data) { - allData.push({ - ...row, - ...(conditionColumn ? { [conditionColumn]: condition } : {}), - }); + const handleConditionalDataChange = useCallback( + (conditionValue: string, newData: any[]) => { + setConditionalTableData((prev) => ({ + ...prev, + [conditionValue]: newData, + })); + + // 부모에게 조건별 데이터 변경 알림 + if (onConditionalTableDataChange) { + onConditionalTableDataChange(conditionValue, newData); } - } - - onTableDataChange(allData); - }, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]); + + // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출 + // (저장 시 조건 컬럼 값이 자동으로 추가됨) + const conditionColumn = conditionalConfig?.conditionColumn; + const allData: any[] = []; + + // 현재 변경된 조건의 데이터 업데이트 + const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData }; + + for (const [condition, data] of Object.entries(updatedConditionalData)) { + for (const row of data) { + allData.push({ + ...row, + ...(conditionColumn ? { [conditionColumn]: condition } : {}), + }); + } + } + + onTableDataChange(allData); + }, + [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange], + ); // 조건부 테이블: 조건별 행 변경 - const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => { - const calculatedRow = calculateRow(newRow); - const currentData = conditionalTableData[conditionValue] || []; - const newData = [...currentData]; - newData[index] = calculatedRow; - handleConditionalDataChange(conditionValue, newData); - }, [conditionalTableData, calculateRow, handleConditionalDataChange]); + const handleConditionalRowChange = useCallback( + (conditionValue: string, index: number, newRow: any) => { + const calculatedRow = calculateRow(newRow); + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[index] = calculatedRow; + handleConditionalDataChange(conditionValue, newData); + }, + [conditionalTableData, calculateRow, handleConditionalDataChange], + ); // 조건부 테이블: 조건별 행 삭제 - const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => { - const currentData = conditionalTableData[conditionValue] || []; - const newData = currentData.filter((_, i) => i !== index); - handleConditionalDataChange(conditionValue, newData); - }, [conditionalTableData, handleConditionalDataChange]); + const handleConditionalRowDelete = useCallback( + (conditionValue: string, index: number) => { + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, i) => i !== index); + handleConditionalDataChange(conditionValue, newData); + }, + [conditionalTableData, handleConditionalDataChange], + ); // 조건부 테이블: 조건별 선택 행 일괄 삭제 - const handleConditionalBulkDelete = useCallback((conditionValue: string) => { - const selected = conditionalSelectedRows[conditionValue] || new Set(); - if (selected.size === 0) return; - - const currentData = conditionalTableData[conditionValue] || []; - const newData = currentData.filter((_, index) => !selected.has(index)); - handleConditionalDataChange(conditionValue, newData); - - // 선택 상태 초기화 - setConditionalSelectedRows((prev) => ({ - ...prev, - [conditionValue]: new Set(), - })); - }, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]); + const handleConditionalBulkDelete = useCallback( + (conditionValue: string) => { + const selected = conditionalSelectedRows[conditionValue] || new Set(); + if (selected.size === 0) return; + + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, index) => !selected.has(index)); + handleConditionalDataChange(conditionValue, newData); + + // 선택 상태 초기화 + setConditionalSelectedRows((prev) => ({ + ...prev, + [conditionValue]: new Set(), + })); + }, + [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange], + ); // 조건부 테이블: 아이템 추가 (특정 조건에) - const handleConditionalAddItems = useCallback(async (items: any[]) => { - if (!modalCondition) return; - - // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 - const mappedItems = await Promise.all( - items.map(async (sourceItem) => { - const newItem: any = {}; - - for (const col of tableConfig.columns) { - const mapping = col.valueMapping; - - // 소스 필드에서 값 복사 (기본) - if (!mapping) { - const sourceField = col.sourceField || col.field; - if (sourceItem[sourceField] !== undefined) { - newItem[col.field] = sourceItem[sourceField]; + const handleConditionalAddItems = useCallback( + async (items: any[]) => { + if (!modalCondition) return; + + // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 + const mappedItems = await Promise.all( + items.map(async (sourceItem) => { + const newItem: any = {}; + + for (const col of tableConfig.columns) { + const mapping = col.valueMapping; + + // 소스 필드에서 값 복사 (기본) + if (!mapping) { + const sourceField = col.sourceField || col.field; + if (sourceItem[sourceField] !== undefined) { + newItem[col.field] = sourceItem[sourceField]; + } + continue; } - continue; - } - - // valueMapping 처리 - if (mapping.type === "source" && mapping.sourceField) { - const value = sourceItem[mapping.sourceField]; - if (value !== undefined) { - newItem[col.field] = value; + + // valueMapping 처리 + if (mapping.type === "source" && mapping.sourceField) { + const value = sourceItem[mapping.sourceField]; + if (value !== undefined) { + newItem[col.field] = value; + } + } else if (mapping.type === "manual") { + newItem[col.field] = col.defaultValue || ""; + } else if (mapping.type === "internal" && mapping.internalField) { + newItem[col.field] = formData[mapping.internalField]; } - } else if (mapping.type === "manual") { - newItem[col.field] = col.defaultValue || ""; - } else if (mapping.type === "internal" && mapping.internalField) { - newItem[col.field] = formData[mapping.internalField]; } - } - - // 원본 소스 데이터 보존 - newItem._sourceData = sourceItem; - - return newItem; - }) - ); - - // 현재 조건의 데이터에 추가 - const currentData = conditionalTableData[modalCondition] || []; - const newData = [...currentData, ...mappedItems]; - handleConditionalDataChange(modalCondition, newData); - - setModalOpen(false); - }, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]); + + // 원본 소스 데이터 보존 + newItem._sourceData = sourceItem; + + return newItem; + }), + ); + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[modalCondition] || []; + const newData = [...currentData, ...mappedItems]; + handleConditionalDataChange(modalCondition, newData); + + setModalOpen(false); + }, + [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange], + ); // 조건부 테이블: 모달 열기 (특정 조건에 대해) const openConditionalModal = useCallback((conditionValue: string) => { @@ -1405,62 +1446,68 @@ export function TableSectionRenderer({ }, []); // 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용) - const addEmptyRowToCondition = useCallback((conditionValue: string) => { - const newRow: Record = {}; - - // 각 컬럼의 기본값으로 빈 행 생성 - for (const col of tableConfig.columns) { - if (col.defaultValue !== undefined) { - newRow[col.field] = col.defaultValue; - } else if (col.type === "number") { - newRow[col.field] = 0; - } else if (col.type === "checkbox") { - newRow[col.field] = false; - } else { - newRow[col.field] = ""; - } - } - - // 조건 컬럼에 현재 조건 값 설정 - if (conditionalConfig?.conditionColumn) { - newRow[conditionalConfig.conditionColumn] = conditionValue; - } - - // 현재 조건의 데이터에 추가 - const currentData = conditionalTableData[conditionValue] || []; - const newData = [...currentData, newRow]; - handleConditionalDataChange(conditionValue, newData); - }, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]); + const addEmptyRowToCondition = useCallback( + (conditionValue: string) => { + const newRow: Record = {}; - // 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작) - const handleAddButtonClick = useCallback((conditionValue: string) => { - const addButtonType = tableConfig.uiConfig?.addButtonType || "search"; - - if (addButtonType === "addRow") { - // 빈 행 직접 추가 - addEmptyRowToCondition(conditionValue); - } else { - // 검색 모달 열기 + // 각 컬럼의 기본값으로 빈 행 생성 + for (const col of tableConfig.columns) { + if (col.defaultValue !== undefined) { + newRow[col.field] = col.defaultValue; + } else if (col.type === "number") { + newRow[col.field] = 0; + } else if (col.type === "checkbox") { + newRow[col.field] = false; + } else { + newRow[col.field] = ""; + } + } + + // 조건 컬럼에 현재 조건 값 설정 + if (conditionalConfig?.conditionColumn) { + newRow[conditionalConfig.conditionColumn] = conditionValue; + } + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData, newRow]; + handleConditionalDataChange(conditionValue, newData); + }, + [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange], + ); + + // 검색 버튼 클릭 핸들러 + const handleSearchButtonClick = useCallback( + (conditionValue: string) => { openConditionalModal(conditionValue); - } - }, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]); + }, + [openConditionalModal], + ); + + // 행 추가 버튼 클릭 핸들러 + const handleAddRowButtonClick = useCallback( + (conditionValue: string) => { + addEmptyRowToCondition(conditionValue); + }, + [addEmptyRowToCondition], + ); // 조건부 테이블: 초기 데이터 로드 (수정 모드) useEffect(() => { if (!isConditionalMode) return; if (initialDataLoadedRef.current) return; - + const tableSectionKey = `_tableSection_${sectionId}`; const initialData = formData[tableSectionKey]; - + if (Array.isArray(initialData) && initialData.length > 0) { const conditionColumn = conditionalConfig?.conditionColumn; - + if (conditionColumn) { // 조건별로 데이터 그룹핑 const grouped: ConditionalTableData = {}; const conditions = new Set(); - + for (const row of initialData) { const conditionValue = row[conditionColumn] || ""; if (conditionValue) { @@ -1471,15 +1518,15 @@ export function TableSectionRenderer({ conditions.add(conditionValue); } } - + setConditionalTableData(grouped); setSelectedConditions(Array.from(conditions)); - + // 첫 번째 조건을 활성 탭으로 설정 if (conditions.size > 0) { setActiveConditionTab(Array.from(conditions)[0]); } - + initialDataLoadedRef.current = true; } } @@ -1495,27 +1542,29 @@ export function TableSectionRenderer({ // ============================================ if (isConditionalMode && conditionalConfig) { const { triggerType } = conditionalConfig; - + // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용) // 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음) - const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 - ? dynamicOptions - : conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== ""); - + const effectiveOptions = ( + conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 + ? dynamicOptions + : conditionalConfig.options || [] + ).filter((opt) => opt.value && opt.value.trim() !== ""); + // 로딩 중이면 로딩 표시 if (dynamicOptionsLoading) { return (
-
+
-
+
조건 옵션을 불러오는 중...
); } - + return (
{/* 조건 선택 UI */} @@ -1525,7 +1574,7 @@ export function TableSectionRenderer({ {effectiveOptions.map((option) => (
- + {selectedConditions.length > 0 && ( -
+
{selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목
)}
)} - + {triggerType === "dropdown" && (
유형 선택: @@ -1566,7 +1615,7 @@ export function TableSectionRenderer({ {effectiveOptions.map((option) => ( {option.label} - {conditionalTableData[option.value]?.length > 0 && + {conditionalTableData[option.value]?.length > 0 && ` (${conditionalTableData[option.value].length})`} ))} @@ -1574,7 +1623,7 @@ export function TableSectionRenderer({
)} - + {/* 선택된 조건들의 테이블 (탭 형태) */} {selectedConditions.length > 0 && ( @@ -1594,17 +1643,17 @@ export function TableSectionRenderer({ ); })} - + {selectedConditions.map((conditionValue) => { const data = conditionalTableData[conditionValue] || []; const selected = conditionalSelectedRows[conditionValue] || new Set(); - + return ( {/* 테이블 상단 컨트롤 */}
- + {data.length > 0 && `${data.length}개 항목`} {selected.size > 0 && ` (${selected.size}개 선택됨)`} @@ -1642,20 +1691,25 @@ export function TableSectionRenderer({ 선택 삭제 ({selected.size}) )} - + {searchButtonText} + + )} + {showAddRowButton && ( + + )}
- + {/* 테이블 */} )} - + {/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */} {triggerType === "tabs" && effectiveOptions.length > 0 && ( - @@ -1702,16 +1756,16 @@ export function TableSectionRenderer({ ); })} - + {effectiveOptions.map((option) => { const data = conditionalTableData[option.value] || []; const selected = conditionalSelectedRows[option.value] || new Set(); - + return (
- + {data.length > 0 && `${data.length}개 항목`} {selected.size > 0 && ` (${selected.size}개 선택됨)`} @@ -1728,20 +1782,25 @@ export function TableSectionRenderer({ 선택 삭제 ({selected.size}) )} - + {searchButtonText} + + )} + {showAddRowButton && ( + + )}
- + )} - + {/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */} {selectedConditions.length === 0 && triggerType !== "tabs" && (
-

- {triggerType === "checkbox" - ? "위에서 유형을 선택하여 검사항목을 추가하세요." - : "유형을 선택하세요."} +

+ {triggerType === "checkbox" ? "위에서 유형을 선택하여 검사항목을 추가하세요." : "유형을 선택하세요."}

)} - + {/* 옵션이 없는 경우 안내 메시지 */} {effectiveOptions.length === 0 && (
-

- 조건 옵션이 설정되지 않았습니다. -

+

조건 옵션이 설정되지 않았습니다.

)} - + {/* 항목 선택 모달 (조건부 테이블용) */} {/* 추가 버튼 영역 */} -
+
- + {tableData.length > 0 && `${tableData.length}개 항목`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} @@ -1822,17 +1877,17 @@ export function TableSectionRenderer({ variant="outline" size="sm" onClick={() => setWidthTrigger((prev) => prev + 1)} - className="h-7 text-xs px-2" + className="h-7 px-2 text-xs" title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"} > {widthTrigger % 2 === 0 ? ( <> - + 자동 맞춤 ) : ( <> - + 균등 분배 )} @@ -1841,17 +1896,20 @@ export function TableSectionRenderer({
{selectedRows.size > 0 && ( - )} - + )} + {showAddRowButton && ( + + }} + className="h-8 text-xs sm:h-10 sm:text-sm" + > + + {addRowButtonText} + + )}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index ebd16c44..d82db59b 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -2928,54 +2928,74 @@ export function TableSectionSettingsModal({ {/* UI 설정 */}

UI 설정

-
-
- - + + {/* 버튼 표시 설정 */} +
+ +

+ 두 버튼을 동시에 표시할 수 있습니다. +

+
+
+ updateUiConfig({ showSearchButton: checked })} + className="scale-75" + /> +
+ 검색 버튼 +

기존 데이터에서 선택

+
+
+
+ updateUiConfig({ showAddRowButton: checked })} + className="scale-75" + /> +
+ 행 추가 버튼 +

빈 행 직접 입력

+
+
+
+ +
+ {/* 검색 버튼 텍스트 */}
- + updateUiConfig({ addButtonText: e.target.value })} - placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"} + value={tableConfig.uiConfig?.searchButtonText || ""} + onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })} + placeholder="품목 검색" className="h-8 text-xs mt-1" + disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)} />
+ {/* 행 추가 버튼 텍스트 */}
- + + updateUiConfig({ addRowButtonText: e.target.value })} + placeholder="직접 입력" + className="h-8 text-xs mt-1" + disabled={!tableConfig.uiConfig?.showAddRowButton} + /> +
+ {/* 모달 제목 */} +
+ updateUiConfig({ modalTitle: e.target.value })} placeholder="항목 검색 및 선택" className="h-8 text-xs mt-1" - disabled={tableConfig.uiConfig?.addButtonType === "addRow"} + disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)} /> - {tableConfig.uiConfig?.addButtonType === "addRow" && ( -

빈 행 추가 모드에서는 모달이 열리지 않습니다

- )}
+ {/* 테이블 최대 높이 */}
+ {/* 다중 선택 허용 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 1f2015eb..a07feed6 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -253,15 +253,19 @@ export interface TableSectionConfig { // 6. UI 설정 uiConfig?: { - addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색") modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") multiSelect?: boolean; // 다중 선택 허용 (기본: true) maxHeight?: string; // 테이블 최대 높이 (기본: "400px") - // 추가 버튼 타입 - // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택 - // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력 + // 버튼 표시 설정 (동시 표시 가능) + showSearchButton?: boolean; // 검색 버튼 표시 (기본: true) + showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false) + searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색") + addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력") + + // 레거시 호환용 (deprecated) addButtonType?: "search" | "addRow"; + addButtonText?: string; }; // 7. 조건부 테이블 설정 (고급) From b45f4870e88efa75364435fce4fe2a8e60e0d6d6 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:03:29 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=EC=8B=A0=EA=B7=9C=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1f067865..ade1c5cc 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -832,7 +832,14 @@ export const ButtonPrimaryComponent: React.FC = ({ } // 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,9 +867,10 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } - // 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단 + // 모달 액션인데 현재 화면(테이블)에서 직접 선택된 데이터가 있으면 경고 메시지 표시하고 중단 // (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지) - if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) { + // 주의: selectedRowsData만 체크 (modalDataStore의 데이터는 이미 제외했으므로) + if (processedConfig.action.type === "modal" && selectedRowsData && selectedRowsData.length > 0) { toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요."); return; } From c78326bae1bbfe1e08d1a79b600aec08f6835493 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:11:42 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config-panels/ButtonConfigPanel.tsx | 73 ++++++++++++++ .../button-primary/ButtonPrimaryComponent.tsx | 99 +++++++++++++++++-- 2 files changed, 165 insertions(+), 7 deletions(-) diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 3a126c29..417ea4ff 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -3081,6 +3081,79 @@ export const ButtonConfigPanel: React.FC = ({ /> )} + {/* 🆕 행 선택 시에만 활성화 설정 */} +
+

행 선택 활성화 조건

+

+ 테이블 리스트나 분할 패널에서 데이터가 선택되었을 때만 버튼을 활성화합니다. +

+ +
+
+ +

+ 체크하면 테이블에서 행을 선택해야만 버튼이 활성화됩니다. +

+
+ { + onUpdateProperty("componentConfig.action.requireRowSelection", checked); + }} + /> +
+ + {component.componentConfig?.action?.requireRowSelection && ( +
+
+ + +

+ 자동 감지: 테이블, 분할 패널, 플로우 위젯 중 선택된 항목이 있으면 활성화 +

+
+ +
+
+ +

+ 여러 행이 선택되어도 활성화 (기본: 1개 이상 선택 시 활성화) +

+
+ { + onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked); + }} + /> +
+ + {!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && ( +
+

+ 정확히 1개의 행만 선택되어야 버튼이 활성화됩니다. +

+
+ )} +
+ )} +
+ {/* 제어 기능 섹션 */}
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index ade1c5cc..8530d8e1 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -296,6 +296,84 @@ export const ButtonPrimaryComponent: React.FC = ({ return false; }, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]); + // 🆕 행 선택 기반 비활성화 조건 계산 + 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; + + // 1. 자동 감지 모드 또는 특정 소스 모드 + if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") { + // TableList에서 선택된 행 확인 (props로 전달됨) + if (selectedRowsData && selectedRowsData.length > 0) { + hasSelection = true; + selectionCount = selectedRowsData.length; + } + // 또는 selectedRows prop 확인 + else if (selectedRows && selectedRows.length > 0) { + hasSelection = true; + selectionCount = selectedRows.length; + } + } + + if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { + // 분할 패널 좌측 선택 데이터 확인 + if (!hasSelection && splitPanelContext?.selectedLeftData) { + hasSelection = true; + selectionCount = 1; + } + } + + if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") { + // 플로우 위젯 선택 데이터 확인 + if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) { + hasSelection = true; + selectionCount = flowSelectedData.length; + } + } + + // 선택된 데이터가 없으면 비활성화 + if (!hasSelection) { + console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label, { + rowSelectionSource, + hasSelection, + }); + return true; + } + + // 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함 + if (!allowMultiRowSelection && selectionCount !== 1) { + console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, { + selectionCount, + allowMultiRowSelection, + }); + return true; + } + + console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, { + selectionCount, + rowSelectionSource, + }); + return false; + }, [ + component.componentConfig?.action, + component.label, + selectedRows, + selectedRowsData, + splitPanelContext?.selectedLeftData, + flowSelectedData, + ]); + // 확인 다이얼로그 상태 const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingAction, setPendingAction] = useState<{ @@ -1096,17 +1174,26 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화) - 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", @@ -1122,10 +1209,8 @@ export const ButtonPrimaryComponent: React.FC = ({ 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 || "버튼"; From 84a3956b02f2f7b4a715b803c764ca041b811458 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:13:26 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 8530d8e1..d79d926f 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -945,13 +945,10 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } - // 모달 액션인데 현재 화면(테이블)에서 직접 선택된 데이터가 있으면 경고 메시지 표시하고 중단 - // (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지) - // 주의: selectedRowsData만 체크 (modalDataStore의 데이터는 이미 제외했으므로) - if (processedConfig.action.type === "modal" && selectedRowsData && selectedRowsData.length > 0) { - toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요."); - return; - } + // 🔧 모달 액션 시 선택 데이터 경고 제거 + // 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나, + // 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함. + // 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨. // 수정(edit) 액션 검증 if (processedConfig.action.type === "edit") { From fb82d2f5a1d10bc7c0fa0c661c5ee687f564e58f Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 14:19:15 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 81 ++++++++++++++++--- .../SplitPanelLayoutComponent.tsx | 20 ++--- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index d79d926f..f311c035 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -296,6 +296,32 @@ export const ButtonPrimaryComponent: React.FC = ({ return false; }, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]); + // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨) + const [modalStoreData, setModalStoreData] = useState>({}); + + // 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; @@ -311,43 +337,76 @@ export const ButtonPrimaryComponent: React.FC = ({ // 선택된 데이터 확인 let hasSelection = false; let selectionCount = 0; + let selectionSource = ""; - // 1. 자동 감지 모드 또는 특정 소스 모드 + // 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") { - // 분할 패널 좌측 선택 데이터 확인 - if (!hasSelection && splitPanelContext?.selectedLeftData) { - hasSelection = true; - selectionCount = 1; + // 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, { - rowSelectionSource, - hasSelection, - }); + console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label); return true; } @@ -362,7 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({ console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, { selectionCount, - rowSelectionSource, + selectionSource, }); return false; }, [ @@ -372,6 +431,8 @@ export const ButtonPrimaryComponent: React.FC = ({ selectedRowsData, splitPanelContext?.selectedLeftData, flowSelectedData, + splitPanelContext, + modalStoreData, ]); // 확인 다이얼로그 상태 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ef91a23d..bfb26c90 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC className="border-border flex flex-shrink-0 flex-col border-r" > -
@@ -2521,14 +2521,14 @@ export const SplitPanelLayoutComponent: React.FC className="flex flex-shrink-0 flex-col" > -
From 58233e51de41f3aaa7bbd5ee38eb5f1f5d5d22a3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 30 Dec 2025 15:28:05 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=EA=B0=81=EA=B0=81=20=EB=B3=84=EB=8F=84?= =?UTF-8?q?=20TSX=20=EB=B3=91=ED=95=A9=20=EB=B0=8F=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/authController.ts | 111 +- backend-node/src/routes/authRoutes.ts | 6 + backend-node/src/services/adminService.ts | 6 +- frontend/app/(main)/admin/menu/page.tsx | 1131 +++++++++++++++- frontend/app/(main)/admin/monitoring/page.tsx | 284 ++++- frontend/app/(main)/admin/page.tsx | 3 +- .../dashboardList/DashboardListClient.tsx | 449 ------- .../screenMng/dashboardList/[id]/page.tsx} | 35 +- .../dashboardList/edit/[id]/page.tsx | 23 - .../screenMng/dashboardList/new/page.tsx | 12 - .../admin/screenMng/dashboardList/page.tsx | 445 ++++++- .../(main)/admin/systemMng/i18nList/page.tsx | 815 +++++++++++- .../[companyCode]/departments/page.tsx | 111 +- .../(main)/admin/userMng/companyList/page.tsx | 86 +- .../admin/userMng/rolesList/[id]/page.tsx | 329 ++++- .../(main)/admin/userMng/rolesList/page.tsx | 337 ++++- .../admin/userMng/userAuthList/page.tsx | 156 ++- .../(main)/admin/userMng/userMngList/page.tsx | 166 ++- frontend/app/(main)/main/page.tsx | 1 - .../components/admin/CompanyManagement.tsx | 93 -- frontend/components/admin/CompanySwitcher.tsx | 195 +++ frontend/components/admin/MenuManagement.tsx | 1136 ----------------- .../components/admin/MonitoringDashboard.tsx | 288 ----- frontend/components/admin/MultiLang.tsx | 859 ------------- .../components/admin/RoleDetailManagement.tsx | 345 ----- frontend/components/admin/RoleManagement.tsx | 335 ----- .../components/admin/UserAuthManagement.tsx | 157 --- frontend/components/admin/UserManagement.tsx | 176 --- .../admin/department/DepartmentManagement.tsx | 117 -- frontend/components/layout/AppLayout.tsx | 108 +- frontend/contexts/MenuContext.tsx | 6 +- frontend/hooks/useAuth.ts | 56 + frontend/lib/api/menu.ts | 4 +- 33 files changed, 4326 insertions(+), 4055 deletions(-) delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx rename frontend/{components/admin/dashboard/DashboardDesigner.tsx => app/(main)/admin/screenMng/dashboardList/[id]/page.tsx} (95%) delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx delete mode 100644 frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx delete mode 100644 frontend/components/admin/CompanyManagement.tsx create mode 100644 frontend/components/admin/CompanySwitcher.tsx delete mode 100644 frontend/components/admin/MenuManagement.tsx delete mode 100644 frontend/components/admin/MonitoringDashboard.tsx delete mode 100644 frontend/components/admin/MultiLang.tsx delete mode 100644 frontend/components/admin/RoleDetailManagement.tsx delete mode 100644 frontend/components/admin/RoleManagement.tsx delete mode 100644 frontend/components/admin/UserAuthManagement.tsx delete mode 100644 frontend/components/admin/UserManagement.tsx delete mode 100644 frontend/components/admin/department/DepartmentManagement.tsx diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 6f72eb10..1903d397 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -141,6 +141,110 @@ export class AuthController { } } + /** + * POST /api/auth/switch-company + * WACE 관리자 전용: 다른 회사로 전환 + */ + static async switchCompany(req: Request, res: Response): Promise { + try { + const { companyCode } = req.body; + const authHeader = req.get("Authorization"); + const token = authHeader && authHeader.split(" ")[1]; + + if (!token) { + res.status(401).json({ + success: false, + message: "인증 토큰이 필요합니다.", + error: { code: "TOKEN_MISSING" }, + }); + return; + } + + // 현재 사용자 정보 확인 + const currentUser = JwtUtils.verifyToken(token); + + // WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인) + // 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함 + if (currentUser.userType !== "SUPER_ADMIN") { + logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`); + res.status(403).json({ + success: false, + message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.", + error: { code: "FORBIDDEN" }, + }); + return; + } + + // 전환할 회사 코드 검증 + if (!companyCode || companyCode.trim() === "") { + res.status(400).json({ + success: false, + message: "전환할 회사 코드가 필요합니다.", + error: { code: "INVALID_INPUT" }, + }); + return; + } + + logger.info(`=== WACE 관리자 회사 전환 ===`, { + userId: currentUser.userId, + originalCompanyCode: currentUser.companyCode, + targetCompanyCode: companyCode, + }); + + // 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만) + if (companyCode !== "*") { + const { query } = await import("../database/db"); + const companies = await query( + "SELECT company_code, company_name FROM company_mng WHERE company_code = $1", + [companyCode] + ); + + if (companies.length === 0) { + res.status(404).json({ + success: false, + message: "존재하지 않는 회사 코드입니다.", + error: { code: "COMPANY_NOT_FOUND" }, + }); + return; + } + } + + // 새로운 JWT 토큰 발급 (company_code만 변경) + const newPersonBean: PersonBean = { + ...currentUser, + companyCode: companyCode.trim(), // 전환할 회사 코드로 변경 + }; + + const newToken = JwtUtils.generateToken(newPersonBean); + + logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`); + + res.status(200).json({ + success: true, + message: "회사 전환 완료", + data: { + token: newToken, + companyCode: companyCode.trim(), + }, + }); + } catch (error) { + logger.error( + `회사 전환 API 오류: ${error instanceof Error ? error.message : error}` + ); + res.status(500).json({ + success: false, + message: "회사 전환 중 오류가 발생했습니다.", + error: { + code: "SERVER_ERROR", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }); + } + } + /** * POST /api/auth/logout * 기존 Java ApiLoginController.logout() 메서드 포팅 @@ -226,13 +330,14 @@ export class AuthController { } // 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환 + // ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원) const userInfoResponse: any = { userId: dbUserInfo.userId, userName: dbUserInfo.userName || "", deptName: dbUserInfo.deptName || "", - companyCode: dbUserInfo.companyCode || "ILSHIN", - company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성 - userType: dbUserInfo.userType || "USER", + companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선 + company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선 + userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선 userTypeName: dbUserInfo.userTypeName || "일반사용자", email: dbUserInfo.email || "", photo: dbUserInfo.photo, diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index adba86e6..7ed87a06 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken); */ router.post("/signup", AuthController.signup); +/** + * POST /api/auth/switch-company + * WACE 관리자 전용: 다른 회사로 전환 + */ +router.post("/switch-company", AuthController.switchCompany); + export default router; diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 5ca6b392..1b9280db 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -412,9 +412,9 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { - // SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시 - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + if (userType === "SUPER_ADMIN") { + // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시 + logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`); authFilter = ""; unionFilter = ""; } else { diff --git a/frontend/app/(main)/admin/menu/page.tsx b/frontend/app/(main)/admin/menu/page.tsx index 4e9ff1d4..85d5b346 100644 --- a/frontend/app/(main)/admin/menu/page.tsx +++ b/frontend/app/(main)/admin/menu/page.tsx @@ -1,9 +1,880 @@ "use client"; -import { MenuManagement } from "@/components/admin/MenuManagement"; +import React, { useState, useEffect, useMemo } from "react"; +import { menuApi } from "@/lib/api/menu"; +import type { MenuItem } from "@/lib/api/menu"; +import { MenuTable } from "@/components/admin/MenuTable"; +import { MenuFormModal } from "@/components/admin/MenuFormModal"; +import { MenuCopyDialog } from "@/components/admin/MenuCopyDialog"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useMenu } from "@/contexts/MenuContext"; +import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang"; +import { useMultiLang } from "@/hooks/useMultiLang"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; // useAuth 추가 import { ScrollToTop } from "@/components/common/ScrollToTop"; +type MenuType = "admin" | "user"; + export default function MenuPage() { + const { adminMenus, userMenus, refreshMenus } = useMenu(); + const { user } = useAuth(); // 현재 사용자 정보 가져오기 + const [selectedMenuType, setSelectedMenuType] = useState("admin"); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [formModalOpen, setFormModalOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [copyDialogOpen, setCopyDialogOpen] = useState(false); + const [selectedMenuId, setSelectedMenuId] = useState(""); + const [selectedMenuName, setSelectedMenuName] = useState(""); + const [selectedMenus, setSelectedMenus] = useState>(new Set()); + + // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시) + const [localAdminMenus, setLocalAdminMenus] = useState([]); + const [localUserMenus, setLocalUserMenus] = useState([]); + + // 다국어 텍스트 훅 사용 + // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용 + const { userLang } = useMultiLang({ companyCode: "*" }); + + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = user?.userType === "SUPER_ADMIN"; + + // 다국어 텍스트 상태 + const [uiTexts, setUiTexts] = useState>({}); + const [uiTextsLoading, setUiTextsLoading] = useState(false); + + // 회사 목록 상태 + const [companies, setCompanies] = useState>([]); + const [selectedCompany, setSelectedCompany] = useState("all"); + const [searchText, setSearchText] = useState(""); + const [expandedMenus, setExpandedMenus] = useState>(new Set()); + const [companySearchText, setCompanySearchText] = useState(""); + const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false); + const [formData, setFormData] = useState({ + menuId: "", + parentId: "", + menuType: "", + level: 0, + parentCompanyCode: "", + }); + + // 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴 + + // 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들) + const MENU_MANAGEMENT_LANG_KEYS = [ + // 페이지 제목 및 설명 + "menu.management.title", + "menu.management.description", + "menu.type.title", + "menu.type.admin", + "menu.type.user", + "menu.management.admin", + "menu.management.user", + "menu.management.admin.description", + "menu.management.user.description", + + // 버튼 + "button.add", + "button.add.top.level", + "button.add.sub", + "button.edit", + "button.delete", + "button.delete.selected", + "button.delete.selected.count", + "button.delete.processing", + "button.cancel", + "button.save", + "button.register", + "button.modify", + + // 필터 및 검색 + "filter.company", + "filter.company.all", + "filter.company.common", + "filter.company.search", + "filter.search", + "filter.search.placeholder", + "filter.reset", + + // 테이블 헤더 + "table.header.select", + "table.header.menu.name", + "table.header.menu.url", + "table.header.menu.type", + "table.header.status", + "table.header.company", + "table.header.sequence", + "table.header.actions", + + // 상태 + "status.active", + "status.inactive", + "status.unspecified", + + // 폼 + "form.menu.type", + "form.menu.type.admin", + "form.menu.type.user", + "form.company", + "form.company.select", + "form.company.common", + "form.company.submenu.note", + "form.lang.key", + "form.lang.key.select", + "form.lang.key.none", + "form.lang.key.search", + "form.lang.key.selected", + "form.menu.name", + "form.menu.name.placeholder", + "form.menu.url", + "form.menu.url.placeholder", + "form.menu.description", + "form.menu.description.placeholder", + "form.menu.sequence", + + // 모달 + "modal.menu.register.title", + "modal.menu.modify.title", + "modal.delete.title", + "modal.delete.description", + "modal.delete.batch.description", + + // 메시지 + "message.loading", + "message.menu.delete.processing", + "message.menu.save.success", + "message.menu.save.failed", + "message.menu.delete.success", + "message.menu.delete.failed", + "message.menu.delete.batch.success", + "message.menu.delete.batch.partial", + "message.menu.status.toggle.success", + "message.menu.status.toggle.failed", + "message.validation.menu.name.required", + "message.validation.company.required", + "message.validation.select.menu.delete", + "message.error.load.menu.list", + "message.error.load.menu.info", + "message.error.load.company.list", + "message.error.load.lang.key.list", + + // 리스트 정보 + "menu.list.title", + "menu.list.total", + "menu.list.search.result", + + // UI + "ui.expand", + "ui.collapse", + "ui.menu.collapse", + "ui.language", + ]; + + // 초기 로딩 + useEffect(() => { + loadCompanies(); + loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시) + // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정 + if (!userLang) { + initializeDefaultTexts(); + } + }, [userLang]); // userLang 변경 시마다 실행 + + // 초기 기본 텍스트 설정 함수 + const initializeDefaultTexts = () => { + const defaultTexts: Record = {}; + MENU_MANAGEMENT_LANG_KEYS.forEach((key) => { + // 기본 한국어 텍스트 제공 + const defaultText = getDefaultText(key); + defaultTexts[key] = defaultText; + }); + setUiTexts(defaultTexts); + // console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length); + }; + + // 기본 텍스트 반환 함수 + const getDefaultText = (key: string): string => { + const defaultTexts: Record = { + "menu.management.title": "메뉴 관리", + "menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.", + "menu.type.title": "메뉴 타입", + "menu.type.admin": "관리자", + "menu.type.user": "사용자", + "menu.management.admin": "관리자 메뉴", + "menu.management.user": "사용자 메뉴", + "menu.management.admin.description": "시스템 관리 및 설정 메뉴", + "menu.management.user.description": "일반 사용자 업무 메뉴", + "button.add": "추가", + "button.add.top.level": "최상위 메뉴 추가", + "button.add.sub": "하위 메뉴 추가", + "button.edit": "수정", + "button.delete": "삭제", + "button.delete.selected": "선택 삭제", + "button.delete.selected.count": "선택 삭제 ({count})", + "button.delete.processing": "삭제 중...", + "button.cancel": "취소", + "button.save": "저장", + "button.register": "등록", + "button.modify": "수정", + "filter.company": "회사", + "filter.company.all": "전체", + "filter.company.common": "공통", + "filter.company.search": "회사 검색", + "filter.search": "검색", + "filter.search.placeholder": "메뉴명 또는 URL로 검색...", + "filter.reset": "초기화", + "table.header.select": "선택", + "table.header.menu.name": "메뉴명", + "table.header.menu.url": "URL", + "table.header.menu.type": "메뉴 타입", + "table.header.status": "상태", + "table.header.company": "회사", + "table.header.sequence": "순서", + "table.header.actions": "작업", + "status.active": "활성화", + "status.inactive": "비활성화", + "status.unspecified": "미지정", + "form.menu.type": "메뉴 타입", + "form.menu.type.admin": "관리자", + "form.menu.type.user": "사용자", + "form.company": "회사", + "form.company.select": "회사를 선택하세요", + "form.company.common": "공통", + "form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.", + "form.lang.key": "다국어 키", + "form.lang.key.select": "다국어 키를 선택하세요", + "form.lang.key.none": "다국어 키 없음", + "form.lang.key.search": "다국어 키 검색...", + "form.lang.key.selected": "선택된 키: {key} - {description}", + "form.menu.name": "메뉴명", + "form.menu.name.placeholder": "메뉴명을 입력하세요", + "form.menu.url": "URL", + "form.menu.url.placeholder": "메뉴 URL을 입력하세요", + "form.menu.description": "설명", + "form.menu.description.placeholder": "메뉴 설명을 입력하세요", + "form.menu.sequence": "순서", + "modal.menu.register.title": "메뉴 등록", + "modal.menu.modify.title": "메뉴 수정", + "modal.delete.title": "메뉴 삭제", + "modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "modal.delete.batch.description": + "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.", + "message.loading": "로딩 중...", + "message.menu.delete.processing": "메뉴 삭제 중...", + "message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.", + "message.menu.save.failed": "메뉴 저장에 실패했습니다.", + "message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.", + "message.menu.delete.failed": "메뉴 삭제에 실패했습니다.", + "message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.", + "message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.", + "message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.", + "message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.", + "message.validation.menu.name.required": "메뉴명을 입력해주세요.", + "message.validation.company.required": "회사를 선택해주세요.", + "message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.", + "message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.", + "message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.", + "message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.", + "message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.", + "menu.list.title": "메뉴 목록", + "menu.list.total": "총 {count}개", + "menu.list.search.result": "검색 결과: {count}개", + "ui.expand": "펼치기", + "ui.collapse": "접기", + "ui.menu.collapse": "메뉴 접기", + "ui.language": "언어", + }; + + return defaultTexts[key] || key; + }; + + // 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드 + useEffect(() => { + if (userLang && !uiTextsLoading) { + loadUITexts(); + } + }, [userLang]); // userLang 변경 시마다 실행 + + // uiTexts 상태 변경 감지 + useEffect(() => { + // console.log("🔄 uiTexts 상태 변경됨:", { + // count: Object.keys(uiTexts).length, + // sampleKeys: Object.keys(uiTexts).slice(0, 5), + // sampleValues: Object.entries(uiTexts) + // .slice(0, 3) + // .map(([k, v]) => `${k}: ${v}`), + // }); + }, [uiTexts]); + + // 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음) + useEffect(() => { + const timer = setTimeout(() => { + if (userLang && !uiTextsLoading) { + // console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드"); + loadUITexts(); + } + }, 300); // 300ms 후 실행 + + return () => clearTimeout(timer); + }, [userLang]); // userLang이 설정된 후 실행 + + // 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드 + useEffect(() => { + const fallbackTimer = setTimeout(() => { + if (!uiTextsLoading && Object.keys(uiTexts).length === 0) { + // console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드"); + // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정 + if (!userLang) { + initializeDefaultTexts(); + } else { + // 사용자 언어가 설정된 경우 다국어 텍스트 로드 + loadUITexts(); + } + } + }, 1000); // 1초 후 실행 + + return () => clearTimeout(fallbackTimer); + }, [userLang]); // userLang 변경 시마다 실행 + + // 번역 로드 이벤트 감지 + useEffect(() => { + const handleTranslationLoaded = (event: CustomEvent) => { + const { key, text, userLang: loadedLang } = event.detail; + if (loadedLang === userLang) { + setUiTexts((prev) => ({ ...prev, [key]: text })); + } + }; + + window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener); + + return () => { + window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener); + }; + }, [userLang]); + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + if (!target.closest(".company-dropdown")) { + setIsCompanyDropdownOpen(false); + setCompanySearchText(""); + } + }; + + if (isCompanyDropdownOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isCompanyDropdownOpen]); + + // 특정 메뉴 타입만 로드하는 함수 + const loadMenusForType = async (type: MenuType, showLoading = true) => { + try { + if (showLoading) { + setLoading(true); + } + + if (type === "admin") { + const adminResponse = await menuApi.getAdminMenusForManagement(); + if (adminResponse.success && adminResponse.data) { + setLocalAdminMenus(adminResponse.data); + } + } else { + const userResponse = await menuApi.getUserMenusForManagement(); + if (userResponse.success && userResponse.data) { + setLocalUserMenus(userResponse.data); + } + } + } catch (error) { + toast.error(getUITextSync("message.error.load.menu.list")); + } finally { + if (showLoading) { + setLoading(false); + } + } + }; + + const loadMenus = async (showLoading = true) => { + // console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`); + try { + if (showLoading) { + setLoading(true); + } + + // 선택된 메뉴 타입에 해당하는 메뉴만 로드 + if (selectedMenuType === "admin") { + const adminResponse = await menuApi.getAdminMenusForManagement(); + if (adminResponse.success && adminResponse.data) { + setLocalAdminMenus(adminResponse.data); + } + } else { + const userResponse = await menuApi.getUserMenusForManagement(); + if (userResponse.success && userResponse.data) { + setLocalUserMenus(userResponse.data); + } + } + + // 전역 메뉴 상태도 업데이트 (좌측 사이드바용) + await refreshMenus(); + // console.log("📋 메뉴 목록 조회 성공"); + } catch (error) { + // console.error("❌ 메뉴 목록 조회 실패:", error); + toast.error(getUITextSync("message.error.load.menu.list")); + } finally { + if (showLoading) { + setLoading(false); + } + } + }; + + // 회사 목록 조회 + const loadCompanies = async () => { + // console.log("🏢 회사 목록 조회 시작"); + try { + const response = await apiClient.get("/admin/companies"); + + if (response.data.success) { + // console.log("🏢 회사 목록 응답:", response.data); + const companyList = response.data.data.map((company: any) => ({ + code: company.company_code || company.companyCode, + name: company.company_name || company.companyName, + })); + // console.log("🏢 변환된 회사 목록:", companyList); + setCompanies(companyList); + } + } catch (error) { + // console.error("❌ 회사 목록 조회 실패:", error); + } + }; + + // 다국어 텍스트 로드 함수 - 배치 API 사용 + const loadUITexts = async () => { + if (uiTextsLoading) return; // 이미 로딩 중이면 중단 + + // userLang이 설정되지 않았으면 기본값 설정 + if (!userLang) { + // console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정"); + const defaultTexts: Record = {}; + MENU_MANAGEMENT_LANG_KEYS.forEach((key) => { + defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용 + }); + setUiTexts(defaultTexts); + return; + } + + // 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화 + if (Object.keys(uiTexts).length === 0) { + // console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화"); + const defaultTexts: Record = {}; + MENU_MANAGEMENT_LANG_KEYS.forEach((key) => { + defaultTexts[key] = getDefaultText(key); + }); + setUiTexts(defaultTexts); + } + + // console.log("🌐 UI 다국어 텍스트 로드 시작", { + // userLang, + // apiParams: { + // companyCode: "*", + // menuCode: "menu.management", + // userLang: userLang, + // }, + // }); + setUiTextsLoading(true); + + try { + // 배치 API를 사용하여 모든 다국어 키를 한 번에 조회 + const response = await apiClient.post( + "/multilang/batch", + { + langKeys: MENU_MANAGEMENT_LANG_KEYS, + companyCode: "*", // 모든 회사 + menuCode: "menu.management", // 메뉴관리 메뉴 + userLang: userLang, // body에 포함 + }, + { + params: {}, // query params는 비움 + }, + ); + + if (response.data.success) { + const translations = response.data.data; + // console.log("🌐 배치 다국어 텍스트 응답:", translations); + + // 번역 결과를 상태에 저장 (기존 uiTexts와 병합) + const mergedTranslations = { ...uiTexts, ...translations }; + // console.log("🔧 setUiTexts 호출 전:", { + // translationsCount: Object.keys(translations).length, + // mergedCount: Object.keys(mergedTranslations).length, + // }); + setUiTexts(mergedTranslations); + // console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations); + + // 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록) + setTranslationCache(userLang, mergedTranslations); + } else { + // console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message); + // API 실패 시에도 기존 uiTexts는 유지 + // console.log("🔄 API 실패로 인해 기존 uiTexts 유지"); + } + } catch (error) { + // console.error("❌ UI 다국어 텍스트 로드 실패:", error); + // API 실패 시에도 기존 uiTexts는 유지 + // console.log("🔄 API 실패로 인해 기존 uiTexts 유지"); + } finally { + setUiTextsLoading(false); + } + }; + + // UI 텍스트 가져오기 함수 (동기 버전만 사용) + // getUIText 함수는 제거 - getUITextSync만 사용 + + // 동기 버전 (DB에서 가져온 번역 텍스트 사용) + const getUITextSync = (key: string, params?: Record, fallback?: string): string => { + // uiTexts에서 번역 텍스트 찾기 + let text = uiTexts[key]; + + // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기 + if (!text) { + text = getMenuTextSync(key, userLang) || fallback || key; + } + + // 파라미터 치환 + if (params && text) { + Object.entries(params).forEach(([paramKey, paramValue]) => { + text = text!.replace(`{${paramKey}}`, String(paramValue)); + }); + } + + return text || key; + }; + + // 다국어 API 테스트 함수 (getUITextSync 사용) + const testMultiLangAPI = async () => { + // console.log("🧪 다국어 API 테스트 시작"); + try { + const text = getUITextSync("menu.management.admin"); + // console.log("🧪 다국어 API 테스트 결과:", text); + } catch (error) { + // console.error("❌ 다국어 API 테스트 실패:", error); + } + }; + + // 대문자 키를 소문자 키로 변환하는 함수 + const convertMenuData = (data: any[]): MenuItem[] => { + return data.map((item) => ({ + objid: item.OBJID || item.objid, + parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id, + menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor, + menu_url: item.MENU_URL || item.menu_url, + menu_desc: item.MENU_DESC || item.menu_desc, + seq: item.SEQ || item.seq, + menu_type: item.MENU_TYPE || item.menu_type, + status: item.STATUS || item.status, + lev: item.LEV || item.lev, + lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor, + status_title: item.STATUS_TITLE || item.status_title, + writer: item.WRITER || item.writer, + regdate: item.REGDATE || item.regdate, + company_code: item.COMPANY_CODE || item.company_code, + company_name: item.COMPANY_NAME || item.company_name, + })); + }; + + const handleAddTopLevelMenu = () => { + setFormData({ + menuId: "", + parentId: "0", // 최상위 메뉴는 parentId가 0 + menuType: getMenuTypeValue(), + level: 1, // 최상위 메뉴는 level 1 + parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음 + }); + setFormModalOpen(true); + }; + + const handleAddMenu = (parentId: string, menuType: string, level: number) => { + // 상위 메뉴의 회사 정보 찾기 + const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; + const parentMenu = currentMenus.find((menu) => menu.objid === parentId); + + setFormData({ + menuId: "", + parentId, + menuType, + level: level + 1, + parentCompanyCode: parentMenu?.company_code || "", + }); + setFormModalOpen(true); + }; + + const handleEditMenu = (menuId: string) => { + // console.log("🔧 메뉴 수정 시작 - menuId:", menuId); + + // 현재 메뉴 정보 찾기 + const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; + const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId); + + if (menuToEdit) { + // console.log("수정할 메뉴 정보:", menuToEdit); + + setFormData({ + menuId: menuId, + parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "", + menuType: selectedMenuType, // 현재 선택된 메뉴 타입 + level: 0, // 기본값 + parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "", + }); + + // console.log("설정된 formData:", { + // menuId: menuId, + // parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "", + // menuType: selectedMenuType, + // level: 0, + // parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "", + // }); + } else { + // console.error("수정할 메뉴를 찾을 수 없음:", menuId); + } + + setFormModalOpen(true); + }; + + const handleMenuSelectionChange = (menuId: string, checked: boolean) => { + const newSelected = new Set(selectedMenus); + if (checked) { + newSelected.add(menuId); + } else { + newSelected.delete(menuId); + } + setSelectedMenus(newSelected); + }; + + const handleSelectAllMenus = (checked: boolean) => { + const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; + if (checked) { + // 모든 메뉴 선택 (최상위 메뉴 포함) + setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || ""))); + } else { + setSelectedMenus(new Set()); + } + }; + + const handleDeleteSelectedMenus = async () => { + if (selectedMenus.size === 0) { + toast.error(getUITextSync("message.validation.select.menu.delete")); + return; + } + + if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) { + return; + } + + setDeleting(true); + try { + const menuIds = Array.from(selectedMenus); + // console.log("삭제할 메뉴 IDs:", menuIds); + + toast.info(getUITextSync("message.menu.delete.processing")); + + const response = await menuApi.deleteMenusBatch(menuIds); + // console.log("삭제 API 응답:", response); + // console.log("응답 구조:", { + // success: response.success, + // data: response.data, + // message: response.message, + // }); + + if (response.success && response.data) { + const { deletedCount, failedCount } = response.data; + // console.log("삭제 결과:", { deletedCount, failedCount }); + + // 선택된 메뉴 초기화 + setSelectedMenus(new Set()); + + // 메뉴 목록 즉시 새로고침 (로딩 상태 없이) + // console.log("메뉴 목록 새로고침 시작"); + await loadMenus(false); + // 전역 메뉴 상태도 업데이트 + await refreshMenus(); + // console.log("메뉴 목록 새로고침 완료"); + + // 삭제 결과 메시지 + if (failedCount === 0) { + toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount })); + } else { + toast.success( + getUITextSync("message.menu.delete.batch.partial", { + success: deletedCount, + failed: failedCount, + }), + ); + } + } else { + // console.error("삭제 실패:", response); + toast.error(response.message || "메뉴 삭제에 실패했습니다."); + } + } catch (error) { + // console.error("메뉴 삭제 중 오류:", error); + toast.error(getUITextSync("message.menu.delete.failed")); + } finally { + setDeleting(false); + } + }; + + const confirmDelete = async () => { + try { + const response = await menuApi.deleteMenu(selectedMenuId); + if (response.success) { + toast.success(response.message); + await loadMenus(false); + } else { + toast.error(response.message); + } + } catch (error) { + toast.error("메뉴 삭제에 실패했습니다."); + } finally { + setDeleteDialogOpen(false); + setSelectedMenuId(""); + } + }; + + const handleCopyMenu = (menuId: string, menuName: string) => { + setSelectedMenuId(menuId); + setSelectedMenuName(menuName); + setCopyDialogOpen(true); + }; + + const handleCopyComplete = async () => { + // 복사 완료 후 메뉴 목록 새로고침 + await loadMenus(false); + toast.success("메뉴 복사가 완료되었습니다"); + }; + + const handleToggleStatus = async (menuId: string) => { + try { + const response = await menuApi.toggleMenuStatus(menuId); + if (response.success) { + toast.success(response.message); + await loadMenus(false); // 메뉴 목록 새로고침 + // 전역 메뉴 상태도 업데이트 + await refreshMenus(); + } else { + toast.error(response.message); + } + } catch (error) { + // console.error("메뉴 상태 토글 오류:", error); + toast.error(getUITextSync("message.menu.status.toggle.failed")); + } + }; + + const handleFormSuccess = () => { + loadMenus(false); + // 전역 메뉴 상태도 업데이트 + refreshMenus(); + }; + + const getCurrentMenus = () => { + // 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용) + const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; + + // 검색어 필터링 + let filteredMenus = currentMenus; + if (searchText.trim()) { + const searchLower = searchText.toLowerCase(); + filteredMenus = currentMenus.filter((menu) => { + const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase(); + const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase(); + return menuName.includes(searchLower) || menuUrl.includes(searchLower); + }); + } + + // 회사 필터링 + if (selectedCompany !== "all") { + filteredMenus = filteredMenus.filter((menu) => { + const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || ""; + return menuCompanyCode === selectedCompany; + }); + } + + return filteredMenus; + }; + + // 메뉴 타입 변경 시 선택된 메뉴 초기화 + const handleMenuTypeChange = (type: MenuType) => { + setSelectedMenuType(type); + setSelectedMenus(new Set()); // 선택된 메뉴 초기화 + setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화 + + // 선택한 메뉴 타입에 해당하는 메뉴만 로드 + if (type === "admin" && localAdminMenus.length === 0) { + loadMenusForType("admin", false); + } else if (type === "user" && localUserMenus.length === 0) { + loadMenusForType("user", false); + } + }; + + const handleToggleExpand = (menuId: string) => { + const newExpandedMenus = new Set(expandedMenus); + if (newExpandedMenus.has(menuId)) { + newExpandedMenus.delete(menuId); + } else { + newExpandedMenus.add(menuId); + } + setExpandedMenus(newExpandedMenus); + }; + + const getMenuTypeString = () => { + return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user"); + }; + + const getMenuTypeValue = () => { + return selectedMenuType === "admin" ? "0" : "1"; + }; + + // uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산 + const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]); + const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]); + const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]); + + // 디버깅을 위한 간단한 상태 표시 + // console.log("🔍 MenuManagement 렌더링 상태:", { + // loading, + // uiTextsLoading, + // uiTextsCount, + // adminMenusCount, + // userMenusCount, + // selectedMenuType, + // userLang, + // }); + + if (loading) { + return ( +
+ +
+ ); + } + return (
@@ -14,7 +885,263 @@ export default function MenuPage() {
{/* 메인 컨텐츠 */} - + +
+ {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */} +
+
+

{getUITextSync("menu.type.title")}

+ + {/* 메뉴 타입 선택 카드들 */} +
+
handleMenuTypeChange("admin")} + > +
+
+

{getUITextSync("menu.management.admin")}

+

+ {getUITextSync("menu.management.admin.description")} +

+
+ + {localAdminMenus.length} + +
+
+ +
handleMenuTypeChange("user")} + > +
+
+

{getUITextSync("menu.management.user")}

+

+ {getUITextSync("menu.management.user.description")} +

+
+ + {localUserMenus.length} + +
+
+
+
+
+ + {/* 우측 메인 영역 - 메뉴 목록 (80%) */} +
+
+ {/* 상단 헤더: 제목 + 검색 + 버튼 */} +
+ {/* 왼쪽: 제목 */} +

+ {getMenuTypeString()} {getUITextSync("menu.list.title")} +

+ + {/* 오른쪽: 검색 + 버튼 */} +
+ {/* 회사 선택 */} +
+
+ + + {isCompanyDropdownOpen && ( +
+
+ setCompanySearchText(e.target.value)} + className="h-8 text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+ +
+
{ + setSelectedCompany("all"); + setIsCompanyDropdownOpen(false); + setCompanySearchText(""); + }} + > + {getUITextSync("filter.company.all")} +
+
{ + setSelectedCompany("*"); + setIsCompanyDropdownOpen(false); + setCompanySearchText(""); + }} + > + {getUITextSync("filter.company.common")} +
+ + {companies + .filter((company) => company.code && company.code.trim() !== "") + .filter( + (company) => + company.name.toLowerCase().includes(companySearchText.toLowerCase()) || + company.code.toLowerCase().includes(companySearchText.toLowerCase()), + ) + .map((company, index) => ( +
{ + setSelectedCompany(company.code); + setIsCompanyDropdownOpen(false); + setCompanySearchText(""); + }} + > + {company.code === "*" ? getUITextSync("filter.company.common") : company.name} +
+ ))} +
+
+ )} +
+
+ + {/* 검색 입력 */} +
+ setSearchText(e.target.value)} + className="h-10 text-sm" + /> +
+ + {/* 초기화 버튼 */} + + + {/* 최상위 메뉴 추가 */} + + + {/* 선택 삭제 */} + {selectedMenus.size > 0 && ( + + )} +
+
+ + {/* 테이블 영역 */} +
+ +
+
+
+
+
+ + setFormModalOpen(false)} + onSuccess={handleFormSuccess} + menuId={formData.menuId} + parentId={formData.parentId} + menuType={formData.menuType} + level={formData.level} + parentCompanyCode={formData.parentCompanyCode} + uiTexts={uiTexts} + /> + + + + + 메뉴 삭제 + + 해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + 삭제 + + + + +
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */} diff --git a/frontend/app/(main)/admin/monitoring/page.tsx b/frontend/app/(main)/admin/monitoring/page.tsx index ac70e9a4..7be9d405 100644 --- a/frontend/app/(main)/admin/monitoring/page.tsx +++ b/frontend/app/(main)/admin/monitoring/page.tsx @@ -1,9 +1,124 @@ "use client"; -import React from "react"; -import MonitoringDashboard from "@/components/admin/MonitoringDashboard"; +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Progress } from "@/components/ui/progress"; +import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react"; +import { toast } from "sonner"; +import { BatchAPI, BatchMonitoring } from "@/lib/api/batch"; export default function MonitoringPage() { + const [monitoring, setMonitoring] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(false); + + useEffect(() => { + loadMonitoringData(); + + let interval: NodeJS.Timeout; + if (autoRefresh) { + interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침 + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [autoRefresh]); + + const loadMonitoringData = async () => { + setIsLoading(true); + try { + const data = await BatchAPI.getBatchMonitoring(); + setMonitoring(data); + } catch (error) { + console.error("모니터링 데이터 조회 오류:", error); + toast.error("모니터링 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleRefresh = () => { + loadMonitoringData(); + }; + + const toggleAutoRefresh = () => { + setAutoRefresh(!autoRefresh); + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return ; + case 'failed': + return ; + case 'running': + return ; + case 'pending': + return ; + default: + return ; + } + }; + + const getStatusBadge = (status: string) => { + const variants = { + completed: "bg-green-100 text-green-800", + failed: "bg-destructive/20 text-red-800", + running: "bg-primary/20 text-blue-800", + pending: "bg-yellow-100 text-yellow-800", + cancelled: "bg-gray-100 text-gray-800", + }; + + const labels = { + completed: "완료", + failed: "실패", + running: "실행 중", + pending: "대기 중", + cancelled: "취소됨", + }; + + return ( + + {labels[status as keyof typeof labels] || status} + + ); + }; + + const formatDuration = (ms: number) => { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; + }; + + const getSuccessRate = () => { + if (!monitoring) return 0; + const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today; + if (total === 0) return 100; + return Math.round((monitoring.successful_jobs_today / total) * 100); + }; + + if (!monitoring) { + return ( +
+
+ +

모니터링 데이터를 불러오는 중...

+
+
+ ); + } + return (
@@ -16,7 +131,170 @@ export default function MonitoringPage() {
{/* 모니터링 대시보드 */} - +
+ {/* 헤더 */} +
+

배치 모니터링

+
+ + +
+
+ + {/* 통계 카드 */} +
+ + + 총 작업 수 +
📋
+
+ +
{monitoring.total_jobs}
+

+ 활성: {monitoring.active_jobs}개 +

+
+
+ + + + 실행 중 +
🔄
+
+ +
{monitoring.running_jobs}
+

+ 현재 실행 중인 작업 +

+
+
+ + + + 오늘 성공 +
+
+ +
{monitoring.successful_jobs_today}
+

+ 성공률: {getSuccessRate()}% +

+
+
+ + + + 오늘 실패 +
+
+ +
{monitoring.failed_jobs_today}
+

+ 주의가 필요한 작업 +

+
+
+
+ + {/* 성공률 진행바 */} + + + 오늘 실행 성공률 + + +
+
+ 성공: {monitoring.successful_jobs_today}건 + 실패: {monitoring.failed_jobs_today}건 +
+ +
+ {getSuccessRate()}% 성공률 +
+
+
+
+ + {/* 최근 실행 이력 */} + + + 최근 실행 이력 + + + {monitoring.recent_executions.length === 0 ? ( +
+ 최근 실행 이력이 없습니다. +
+ ) : ( + + + + 상태 + 작업 ID + 시작 시간 + 완료 시간 + 실행 시간 + 오류 메시지 + + + + {monitoring.recent_executions.map((execution) => ( + + +
+ {getStatusIcon(execution.execution_status)} + {getStatusBadge(execution.execution_status)} +
+
+ #{execution.job_id} + + {execution.started_at + ? new Date(execution.started_at).toLocaleString() + : "-"} + + + {execution.completed_at + ? new Date(execution.completed_at).toLocaleString() + : "-"} + + + {execution.execution_time_ms + ? formatDuration(execution.execution_time_ms) + : "-"} + + + {execution.error_message ? ( + + {execution.error_message} + + ) : ( + "-" + )} + +
+ ))} +
+
+ )} +
+
+
); diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index 3060e0fc..8658d7c6 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -1,4 +1,4 @@ -import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react"; +import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react"; import Link from "next/link"; import { GlobalFileViewer } from "@/components/GlobalFileViewer"; @@ -9,6 +9,7 @@ export default function AdminPage() { return (
+ {/* 주요 관리 기능 */}
diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx deleted file mode 100644 index c50aaa51..00000000 --- a/frontend/app/(main)/admin/screenMng/dashboardList/DashboardListClient.tsx +++ /dev/null @@ -1,449 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { dashboardApi } from "@/lib/api/dashboard"; -import { Dashboard } from "@/lib/api/dashboard"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { useToast } from "@/hooks/use-toast"; -import { Pagination, PaginationInfo } from "@/components/common/Pagination"; -import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; -import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; - -/** - * 대시보드 목록 클라이언트 컴포넌트 - * - CSR 방식으로 초기 데이터 로드 - * - 대시보드 목록 조회 - * - 대시보드 생성/수정/삭제/복사 - */ -export default function DashboardListClient() { - const router = useRouter(); - const { toast } = useToast(); - - // 상태 관리 - const [dashboards, setDashboards] = useState([]); - const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true - const [error, setError] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - - // 페이지네이션 상태 - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [totalCount, setTotalCount] = useState(0); - - // 모달 상태 - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); - - // 대시보드 목록 로드 - const loadDashboards = async () => { - try { - setLoading(true); - setError(null); - const result = await dashboardApi.getMyDashboards({ - search: searchTerm, - page: currentPage, - limit: pageSize, - }); - setDashboards(result.dashboards); - setTotalCount(result.pagination.total); - } catch (err) { - console.error("Failed to load dashboards:", err); - setError( - err instanceof Error - ? err.message - : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.", - ); - } finally { - setLoading(false); - } - }; - - // 검색어/페이지 변경 시 fetch (초기 로딩 포함) - useEffect(() => { - loadDashboards(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm, currentPage, pageSize]); - - // 페이지네이션 정보 계산 - const paginationInfo: PaginationInfo = { - currentPage, - totalPages: Math.ceil(totalCount / pageSize) || 1, - totalItems: totalCount, - itemsPerPage: pageSize, - startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, - endItem: Math.min(currentPage * pageSize, totalCount), - }; - - // 페이지 변경 핸들러 - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; - - // 페이지 크기 변경 핸들러 - const handlePageSizeChange = (size: number) => { - setPageSize(size); - setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 - }; - - // 대시보드 삭제 확인 모달 열기 - const handleDeleteClick = (id: string, title: string) => { - setDeleteTarget({ id, title }); - setDeleteDialogOpen(true); - }; - - // 대시보드 삭제 실행 - const handleDeleteConfirm = async () => { - if (!deleteTarget) return; - - try { - await dashboardApi.deleteDashboard(deleteTarget.id); - setDeleteDialogOpen(false); - setDeleteTarget(null); - toast({ - title: "성공", - description: "대시보드가 삭제되었습니다.", - }); - loadDashboards(); - } catch (err) { - console.error("Failed to delete dashboard:", err); - setDeleteDialogOpen(false); - toast({ - title: "오류", - description: "대시보드 삭제에 실패했습니다.", - variant: "destructive", - }); - } - }; - - // 대시보드 복사 - const handleCopy = async (dashboard: Dashboard) => { - try { - const fullDashboard = await dashboardApi.getDashboard(dashboard.id); - - await dashboardApi.createDashboard({ - title: `${fullDashboard.title} (복사본)`, - description: fullDashboard.description, - elements: fullDashboard.elements || [], - isPublic: false, - tags: fullDashboard.tags, - category: fullDashboard.category, - settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, - }); - toast({ - title: "성공", - description: "대시보드가 복사되었습니다.", - }); - loadDashboards(); - } catch (err) { - console.error("Failed to copy dashboard:", err); - toast({ - title: "오류", - description: "대시보드 복사에 실패했습니다.", - variant: "destructive", - }); - } - }; - - // 포맷팅 헬퍼 - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - }; - - return ( - <> - {/* 검색 및 액션 */} -
-
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> -
-
- 총 {totalCount.toLocaleString()} 건 -
-
- -
- - {/* 대시보드 목록 */} - {loading ? ( - <> - {/* 데스크톱 테이블 스켈레톤 */} -
- - - - 제목 - 설명 - 생성자 - 생성일 - 수정일 - 작업 - - - - {Array.from({ length: 10 }).map((_, index) => ( - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
-
-
- - {/* 모바일/태블릿 카드 스켈레톤 */} -
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
-
-
-
-
- {Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
- ))} -
-
- ))} -
- - ) : error ? ( -
-
-
- -
-
-

데이터를 불러올 수 없습니다

-

{error}

-
- -
-
- ) : dashboards.length === 0 ? ( -
-
-

대시보드가 없습니다

-
-
- ) : ( - <> - {/* 데스크톱 테이블 뷰 (lg 이상) */} -
- - - - 제목 - 설명 - 생성자 - 생성일 - 수정일 - 작업 - - - - {dashboards.map((dashboard) => ( - - - - - - {dashboard.description || "-"} - - - {dashboard.createdByName || dashboard.createdBy || "-"} - - - {formatDate(dashboard.createdAt)} - - - {formatDate(dashboard.updatedAt)} - - - - - - - - router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)} - className="gap-2 text-sm" - > - - 편집 - - handleCopy(dashboard)} className="gap-2 text-sm"> - - 복사 - - handleDeleteClick(dashboard.id, dashboard.title)} - className="text-destructive focus:text-destructive gap-2 text-sm" - > - - 삭제 - - - - - - ))} - -
-
- - {/* 모바일/태블릿 카드 뷰 (lg 미만) */} -
- {dashboards.map((dashboard) => ( -
- {/* 헤더 */} -
-
- -

{dashboard.id}

-
-
- - {/* 정보 */} -
-
- 설명 - {dashboard.description || "-"} -
-
- 생성자 - {dashboard.createdByName || dashboard.createdBy || "-"} -
-
- 생성일 - {formatDate(dashboard.createdAt)} -
-
- 수정일 - {formatDate(dashboard.updatedAt)} -
-
- - {/* 액션 */} -
- - - -
-
- ))} -
- - )} - - {/* 페이지네이션 */} - {!loading && dashboards.length > 0 && ( - - )} - - {/* 삭제 확인 모달 */} - - "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. - - } - onConfirm={handleDeleteConfirm} - /> - - ); -} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx similarity index 95% rename from frontend/components/admin/dashboard/DashboardDesigner.tsx rename to frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx index b945cb3d..63900c78 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/app/(main)/admin/screenMng/dashboardList/[id]/page.tsx @@ -1,17 +1,18 @@ "use client"; import React, { useState, useRef, useCallback } from "react"; +import { use } from "react"; import { useRouter } from "next/navigation"; -import { DashboardCanvas } from "./DashboardCanvas"; -import { DashboardTopMenu } from "./DashboardTopMenu"; -import { WidgetConfigSidebar } from "./WidgetConfigSidebar"; -import { DashboardSaveModal } from "./DashboardSaveModal"; -import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils"; -import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; +import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas"; +import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu"; +import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar"; +import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal"; +import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types"; +import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils"; +import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; -import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; +import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts"; import { Dialog, DialogContent, @@ -32,18 +33,24 @@ import { import { Button } from "@/components/ui/button"; import { CheckCircle2 } from "lucide-react"; -interface DashboardDesignerProps { - dashboardId?: string; -} - /** - * 대시보드 설계 도구 메인 컴포넌트 + * 대시보드 생성/편집 페이지 + * URL: /admin/screenMng/dashboardList/[id] + * - id가 "new"면 새 대시보드 생성 + * - id가 숫자면 기존 대시보드 편집 + * + * 기능: * - 드래그 앤 드롭으로 차트/위젯 배치 * - 그리드 기반 레이아웃 (12 컬럼) * - 요소 이동, 크기 조절, 삭제 기능 * - 레이아웃 저장/불러오기 기능 */ -export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) { +export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) { + const { id: paramId } = use(params); + + // "new"면 생성 모드, 아니면 편집 모드 + const initialDashboardId = paramId === "new" ? undefined : paramId; + const router = useRouter(); const { refreshMenus } = useMenu(); const [elements, setElements] = useState([]); diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx deleted file mode 100644 index 92220b6c..00000000 --- a/frontend/app/(main)/admin/screenMng/dashboardList/edit/[id]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import React from "react"; -import { use } from "react"; -import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; - -interface PageProps { - params: Promise<{ id: string }>; -} - -/** - * 대시보드 편집 페이지 - * - 기존 대시보드 편집 - */ -export default function DashboardEditPage({ params }: PageProps) { - const { id } = use(params); - - return ( -
- -
- ); -} diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx deleted file mode 100644 index 56d28f46..00000000 --- a/frontend/app/(main)/admin/screenMng/dashboardList/new/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; - -/** - * 새 대시보드 생성 페이지 - */ -export default function DashboardNewPage() { - return ( -
- -
- ); -} diff --git a/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx index 62587c54..c346dc54 100644 --- a/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/dashboardList/page.tsx @@ -1,11 +1,167 @@ -import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient"; +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { Dashboard } from "@/lib/api/dashboard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useToast } from "@/hooks/use-toast"; +import { Pagination, PaginationInfo } from "@/components/common/Pagination"; +import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; +import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; /** * 대시보드 관리 페이지 - * - 클라이언트 컴포넌트를 렌더링하는 래퍼 - * - 초기 로딩부터 CSR로 처리 + * - CSR 방식으로 초기 데이터 로드 + * - 대시보드 목록 조회 + * - 대시보드 생성/수정/삭제/복사 */ export default function DashboardListPage() { + const router = useRouter(); + const { toast } = useToast(); + + // 상태 관리 + const [dashboards, setDashboards] = useState([]); + const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); + + // 모달 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); + + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + setLoading(true); + setError(null); + const result = await dashboardApi.getMyDashboards({ + search: searchTerm, + page: currentPage, + limit: pageSize, + }); + setDashboards(result.dashboards); + setTotalCount(result.pagination.total); + } catch (err) { + console.error("Failed to load dashboards:", err); + setError( + err instanceof Error + ? err.message + : "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.", + ); + } finally { + setLoading(false); + } + }; + + // 검색어/페이지 변경 시 fetch (초기 로딩 포함) + useEffect(() => { + loadDashboards(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchTerm, currentPage, pageSize]); + + // 페이지네이션 정보 계산 + const paginationInfo: PaginationInfo = { + currentPage, + totalPages: Math.ceil(totalCount / pageSize) || 1, + totalItems: totalCount, + itemsPerPage: pageSize, + startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, + endItem: Math.min(currentPage * pageSize, totalCount), + }; + + // 페이지 변경 핸들러 + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // 페이지 크기 변경 핸들러 + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 + }; + + // 대시보드 삭제 확인 모달 열기 + const handleDeleteClick = (id: string, title: string) => { + setDeleteTarget({ id, title }); + setDeleteDialogOpen(true); + }; + + // 대시보드 삭제 실행 + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + try { + await dashboardApi.deleteDashboard(deleteTarget.id); + setDeleteDialogOpen(false); + setDeleteTarget(null); + toast({ + title: "성공", + description: "대시보드가 삭제되었습니다.", + }); + loadDashboards(); + } catch (err) { + console.error("Failed to delete dashboard:", err); + setDeleteDialogOpen(false); + toast({ + title: "오류", + description: "대시보드 삭제에 실패했습니다.", + variant: "destructive", + }); + } + }; + + // 대시보드 복사 + const handleCopy = async (dashboard: Dashboard) => { + try { + const fullDashboard = await dashboardApi.getDashboard(dashboard.id); + + await dashboardApi.createDashboard({ + title: `${fullDashboard.title} (복사본)`, + description: fullDashboard.description, + elements: fullDashboard.elements || [], + isPublic: false, + tags: fullDashboard.tags, + category: fullDashboard.category, + settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, + }); + toast({ + title: "성공", + description: "대시보드가 복사되었습니다.", + }); + loadDashboards(); + } catch (err) { + console.error("Failed to copy dashboard:", err); + toast({ + title: "오류", + description: "대시보드 복사에 실패했습니다.", + variant: "destructive", + }); + } + }; + + // 포맷팅 헬퍼 + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + }; + return (
@@ -15,8 +171,287 @@ export default function DashboardListPage() {

대시보드를 생성하고 관리할 수 있습니다

- {/* 클라이언트 컴포넌트 */} - + {/* 검색 및 액션 */} +
+
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ 총 {totalCount.toLocaleString()} 건 +
+
+ +
+ + {/* 대시보드 목록 */} + {loading ? ( + <> + {/* 데스크톱 테이블 스켈레톤 */} +
+ + + + 제목 + 설명 + 생성자 + 생성일 + 수정일 + 작업 + + + + {Array.from({ length: 10 }).map((_, index) => ( + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+
+ + {/* 모바일/태블릿 카드 스켈레톤 */} +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+ ))} +
+ + ) : error ? ( +
+
+
+ +
+
+

데이터를 불러올 수 없습니다

+

{error}

+
+ +
+
+ ) : dashboards.length === 0 ? ( +
+
+

대시보드가 없습니다

+
+
+ ) : ( + <> + {/* 데스크톱 테이블 뷰 (lg 이상) */} +
+ + + + 제목 + 설명 + 생성자 + 생성일 + 수정일 + 작업 + + + + {dashboards.map((dashboard) => ( + + + + + + {dashboard.description || "-"} + + + {dashboard.createdByName || dashboard.createdBy || "-"} + + + {formatDate(dashboard.createdAt)} + + + {formatDate(dashboard.updatedAt)} + + + + + + + + router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)} + className="gap-2 text-sm" + > + + 편집 + + handleCopy(dashboard)} className="gap-2 text-sm"> + + 복사 + + handleDeleteClick(dashboard.id, dashboard.title)} + className="text-destructive focus:text-destructive gap-2 text-sm" + > + + 삭제 + + + + + + ))} + +
+
+ + {/* 모바일/태블릿 카드 뷰 (lg 미만) */} +
+ {dashboards.map((dashboard) => ( +
+ {/* 헤더 */} +
+
+ +

{dashboard.id}

+
+
+ + {/* 정보 */} +
+
+ 설명 + {dashboard.description || "-"} +
+
+ 생성자 + {dashboard.createdByName || dashboard.createdBy || "-"} +
+
+ 생성일 + {formatDate(dashboard.createdAt)} +
+
+ 수정일 + {formatDate(dashboard.updatedAt)} +
+
+ + {/* 액션 */} +
+ + + +
+
+ ))} +
+ + )} + + {/* 페이지네이션 */} + {!loading && dashboards.length > 0 && ( + + )} + + {/* 삭제 확인 모달 */} + + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. + + } + onConfirm={handleDeleteConfirm} + />
); diff --git a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx index 48655de7..3acce6fb 100644 --- a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx @@ -1,12 +1,823 @@ "use client"; -import MultiLang from "@/components/admin/MultiLang"; +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +import { DataTable } from "@/components/common/DataTable"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { useAuth } from "@/hooks/useAuth"; +import LangKeyModal from "@/components/admin/LangKeyModal"; +import LanguageModal from "@/components/admin/LanguageModal"; +import { apiClient } from "@/lib/api/client"; + +interface Language { + langCode: string; + langName: string; + langNative: string; + isActive: string; +} + +interface LangKey { + keyId: number; + companyCode: string; + menuName: string; + langKey: string; + description: string; + isActive: string; +} + +interface LangText { + textId: number; + keyId: number; + langCode: string; + langText: string; + isActive: string; +} export default function I18nPage() { + const { user } = useAuth(); + const [loading, setLoading] = useState(true); + const [languages, setLanguages] = useState([]); + const [langKeys, setLangKeys] = useState([]); + const [selectedKey, setSelectedKey] = useState(null); + const [langTexts, setLangTexts] = useState([]); + const [editingTexts, setEditingTexts] = useState([]); + const [selectedCompany, setSelectedCompany] = useState("all"); + const [searchText, setSearchText] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingKey, setEditingKey] = useState(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + // 언어 관리 관련 상태 + const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false); + const [editingLanguage, setEditingLanguage] = useState(null); + const [selectedLanguages, setSelectedLanguages] = useState>(new Set()); + const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys"); + + const [companies, setCompanies] = useState>([]); + + // 회사 목록 조회 + const fetchCompanies = async () => { + try { + const response = await apiClient.get("/admin/companies"); + const data = response.data; + if (data.success) { + const companyList = data.data.map((company: any) => ({ + code: company.company_code, + name: company.company_name, + })); + setCompanies(companyList); + } + } catch (error) { + // console.error("회사 목록 조회 실패:", error); + } + }; + + // 언어 목록 조회 + const fetchLanguages = async () => { + try { + const response = await apiClient.get("/multilang/languages"); + const data = response.data; + if (data.success) { + setLanguages(data.data); + } + } catch (error) { + // console.error("언어 목록 조회 실패:", error); + } + }; + + // 다국어 키 목록 조회 + const fetchLangKeys = async () => { + try { + const response = await apiClient.get("/multilang/keys"); + const data = response.data; + if (data.success) { + setLangKeys(data.data); + } + } catch (error) { + // console.error("다국어 키 목록 조회 실패:", error); + } + }; + + // 필터링된 데이터 계산 + const getFilteredLangKeys = () => { + let filteredKeys = langKeys; + + // 회사 필터링 + if (selectedCompany && selectedCompany !== "all") { + filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany); + } + + // 텍스트 검색 필터링 + if (searchText.trim()) { + const searchLower = searchText.toLowerCase(); + filteredKeys = filteredKeys.filter((key) => { + const langKey = (key.langKey || "").toLowerCase(); + const description = (key.description || "").toLowerCase(); + const menuName = (key.menuName || "").toLowerCase(); + const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || ""; + + return ( + langKey.includes(searchLower) || + description.includes(searchLower) || + menuName.includes(searchLower) || + companyName.includes(searchLower) + ); + }); + } + + return filteredKeys; + }; + + // 선택된 키의 다국어 텍스트 조회 + const fetchLangTexts = async (keyId: number) => { + try { + const response = await apiClient.get(`/multilang/keys/${keyId}/texts`); + const data = response.data; + if (data.success) { + setLangTexts(data.data); + const editingData = data.data.map((text: LangText) => ({ ...text })); + setEditingTexts(editingData); + } + } catch (error) { + // console.error("다국어 텍스트 조회 실패:", error); + } + }; + + // 언어 키 선택 처리 + const handleKeySelect = (key: LangKey) => { + setSelectedKey(key); + fetchLangTexts(key.keyId); + }; + + // 텍스트 변경 처리 + const handleTextChange = (langCode: string, value: string) => { + const newEditingTexts = [...editingTexts]; + const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode); + + if (existingIndex >= 0) { + newEditingTexts[existingIndex].langText = value; + } else { + newEditingTexts.push({ + textId: 0, + keyId: selectedKey!.keyId, + langCode: langCode, + langText: value, + isActive: "Y", + }); + } + + setEditingTexts(newEditingTexts); + }; + + // 텍스트 저장 + const handleSave = async () => { + if (!selectedKey) return; + + try { + const requestData = { + texts: editingTexts.map((text) => ({ + langCode: text.langCode, + langText: text.langText, + isActive: text.isActive || "Y", + createdBy: user?.userId || "system", + updatedBy: user?.userId || "system", + })), + }; + + const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData); + const data = response.data; + if (data.success) { + alert("저장되었습니다."); + fetchLangTexts(selectedKey.keyId); + } + } catch (error) { + alert("저장에 실패했습니다."); + } + }; + + // 언어 키 추가/수정 모달 열기 + const handleAddKey = () => { + setEditingKey(null); + setIsModalOpen(true); + }; + + // 언어 추가/수정 모달 열기 + const handleAddLanguage = () => { + setEditingLanguage(null); + setIsLanguageModalOpen(true); + }; + + // 언어 수정 + const handleEditLanguage = (language: Language) => { + setEditingLanguage(language); + setIsLanguageModalOpen(true); + }; + + // 언어 저장 (추가/수정) + const handleSaveLanguage = async (languageData: any) => { + try { + const requestData = { + ...languageData, + createdBy: user?.userId || "admin", + updatedBy: user?.userId || "admin", + }; + + let response; + if (editingLanguage) { + response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData); + } else { + response = await apiClient.post("/multilang/languages", requestData); + } + + const result = response.data; + + if (result.success) { + alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다."); + setIsLanguageModalOpen(false); + fetchLanguages(); + } else { + alert(`오류: ${result.message}`); + } + } catch (error) { + alert("언어 저장 중 오류가 발생했습니다."); + } + }; + + // 언어 삭제 + const handleDeleteLanguages = async () => { + if (selectedLanguages.size === 0) { + alert("삭제할 언어를 선택해주세요."); + return; + } + + if ( + !confirm( + `선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`, + ) + ) { + return; + } + + try { + const deletePromises = Array.from(selectedLanguages).map((langCode) => + apiClient.delete(`/multilang/languages/${langCode}`), + ); + + const responses = await Promise.all(deletePromises); + const failedDeletes = responses.filter((response) => !response.data.success); + + if (failedDeletes.length === 0) { + alert("선택된 언어가 삭제되었습니다."); + setSelectedLanguages(new Set()); + fetchLanguages(); + } else { + alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`); + } + } catch (error) { + alert("언어 삭제 중 오류가 발생했습니다."); + } + }; + + // 언어 선택 체크박스 처리 + const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => { + const newSelected = new Set(selectedLanguages); + if (checked) { + newSelected.add(langCode); + } else { + newSelected.delete(langCode); + } + setSelectedLanguages(newSelected); + }; + + // 언어 전체 선택/해제 + const handleSelectAllLanguages = (checked: boolean) => { + if (checked) { + setSelectedLanguages(new Set(languages.map((lang) => lang.langCode))); + } else { + setSelectedLanguages(new Set()); + } + }; + + // 언어 키 수정 모달 열기 + const handleEditKey = (key: LangKey) => { + setEditingKey(key); + setIsModalOpen(true); + }; + + // 언어 키 저장 (추가/수정) + const handleSaveKey = async (keyData: any) => { + try { + const requestData = { + ...keyData, + createdBy: user?.userId || "admin", + updatedBy: user?.userId || "admin", + }; + + let response; + if (editingKey) { + response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData); + } else { + response = await apiClient.post("/multilang/keys", requestData); + } + + const data = response.data; + + if (data.success) { + alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다."); + fetchLangKeys(); + setIsModalOpen(false); + } else { + if (data.message && data.message.includes("이미 존재하는 언어키")) { + alert(data.message); + } else { + alert(data.message || "언어 키 저장에 실패했습니다."); + } + } + } catch (error) { + alert("언어 키 저장에 실패했습니다."); + } + }; + + // 체크박스 선택/해제 + const handleCheckboxChange = (keyId: number, checked: boolean) => { + const newSelectedKeys = new Set(selectedKeys); + if (checked) { + newSelectedKeys.add(keyId); + } else { + newSelectedKeys.delete(keyId); + } + setSelectedKeys(newSelectedKeys); + }; + + // 키 상태 토글 + const handleToggleStatus = async (keyId: number) => { + try { + const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`); + const data = response.data; + if (data.success) { + alert(`키가 ${data.data}되었습니다.`); + fetchLangKeys(); + } else { + alert("상태 변경 중 오류가 발생했습니다."); + } + } catch (error) { + alert("키 상태 변경 중 오류가 발생했습니다."); + } + }; + + // 언어 상태 토글 + const handleToggleLanguageStatus = async (langCode: string) => { + try { + const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`); + const data = response.data; + if (data.success) { + alert(`언어가 ${data.data}되었습니다.`); + fetchLanguages(); + } else { + alert("언어 상태 변경 중 오류가 발생했습니다."); + } + } catch (error) { + alert("언어 상태 변경 중 오류가 발생했습니다."); + } + }; + + // 전체 선택/해제 + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allKeyIds = getFilteredLangKeys().map((key) => key.keyId); + setSelectedKeys(new Set(allKeyIds)); + } else { + setSelectedKeys(new Set()); + } + }; + + // 선택된 키들 일괄 삭제 + const handleDeleteSelectedKeys = async () => { + if (selectedKeys.size === 0) { + alert("삭제할 키를 선택해주세요."); + return; + } + + if ( + !confirm( + `선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`, + ) + ) { + return; + } + + try { + const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`)); + + const responses = await Promise.all(deletePromises); + const allSuccess = responses.every((response) => response.data.success); + + if (allSuccess) { + alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`); + setSelectedKeys(new Set()); + fetchLangKeys(); + + if (selectedKey && selectedKeys.has(selectedKey.keyId)) { + handleCancel(); + } + } else { + alert("일부 키 삭제에 실패했습니다."); + } + } catch (error) { + alert("선택된 키 삭제에 실패했습니다."); + } + }; + + // 개별 키 삭제 + const handleDeleteKey = async (keyId: number) => { + if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) { + return; + } + + try { + const response = await apiClient.delete(`/multilang/keys/${keyId}`); + const data = response.data; + if (data.success) { + alert("언어 키가 영구적으로 삭제되었습니다."); + fetchLangKeys(); + if (selectedKey && selectedKey.keyId === keyId) { + handleCancel(); + } + } + } catch (error) { + alert("언어 키 삭제에 실패했습니다."); + } + }; + + // 취소 처리 + const handleCancel = () => { + setSelectedKey(null); + setLangTexts([]); + setEditingTexts([]); + }; + + useEffect(() => { + const initializeData = async () => { + setLoading(true); + await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]); + setLoading(false); + }; + initializeData(); + }, []); + + const columns = [ + { + id: "select", + header: () => { + const filteredKeys = getFilteredLangKeys(); + return ( + 0} + onChange={(e) => handleSelectAll(e.target.checked)} + className="h-4 w-4" + /> + ); + }, + cell: ({ row }: any) => ( + handleCheckboxChange(row.original.keyId, e.target.checked)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4" + disabled={row.original.isActive === "N"} + /> + ), + }, + { + accessorKey: "companyCode", + header: "회사", + cell: ({ row }: any) => { + const companyName = + row.original.companyCode === "*" + ? "공통" + : companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode; + + return {companyName}; + }, + }, + { + accessorKey: "menuName", + header: "메뉴명", + cell: ({ row }: any) => ( + {row.original.menuName} + ), + }, + { + accessorKey: "langKey", + header: "언어 키", + cell: ({ row }: any) => ( +
handleEditKey(row.original)} + > + {row.original.langKey} +
+ ), + }, + { + accessorKey: "description", + header: "설명", + cell: ({ row }: any) => ( + {row.original.description} + ), + }, + { + accessorKey: "isActive", + header: "상태", + cell: ({ row }: any) => ( + + ), + }, + ]; + + // 언어 테이블 컬럼 정의 + const languageColumns = [ + { + id: "select", + header: () => ( + 0} + onChange={(e) => handleSelectAllLanguages(e.target.checked)} + className="h-4 w-4" + /> + ), + cell: ({ row }: any) => ( + handleLanguageCheckboxChange(row.original.langCode, e.target.checked)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4" + disabled={row.original.isActive === "N"} + /> + ), + }, + { + accessorKey: "langCode", + header: "언어 코드", + cell: ({ row }: any) => ( +
handleEditLanguage(row.original)} + > + {row.original.langCode} +
+ ), + }, + { + accessorKey: "langName", + header: "언어명 (영문)", + cell: ({ row }: any) => ( + {row.original.langName} + ), + }, + { + accessorKey: "langNative", + header: "언어명 (원어)", + cell: ({ row }: any) => ( + {row.original.langNative} + ), + }, + { + accessorKey: "isActive", + header: "상태", + cell: ({ row }: any) => ( + + ), + }, + ]; + + if (loading) { + return ; + } + return (
- +
+ {/* 탭 네비게이션 */} +
+ + +
+ + {/* 메인 콘텐츠 영역 */} +
+ {/* 언어 관리 탭 */} + {activeTab === "languages" && ( + + + 언어 관리 + + +
+
총 {languages.length}개의 언어가 등록되어 있습니다.
+
+ {selectedLanguages.size > 0 && ( + + )} + +
+
+ +
+
+ )} + + {/* 다국어 키 관리 탭 */} + {activeTab === "keys" && ( +
+ {/* 좌측: 언어 키 목록 (7/10) */} + + +
+ 언어 키 목록 +
+ + +
+
+
+ + {/* 검색 필터 영역 */} +
+
+ + +
+ +
+ + setSearchText(e.target.value)} + /> +
+ +
+
검색 결과: {getFilteredLangKeys().length}건
+
+
+ + {/* 테이블 영역 */} +
+
전체: {getFilteredLangKeys().length}건
+ +
+
+
+ + {/* 우측: 선택된 키의 다국어 관리 (3/10) */} + + + + {selectedKey ? ( + <> + 선택된 키:{" "} + + {selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey} + + + ) : ( + "다국어 편집" + )} + + + + {selectedKey ? ( +
+ {/* 스크롤 가능한 텍스트 영역 */} +
+ {languages + .filter((lang) => lang.isActive === "Y") + .map((lang) => { + const text = editingTexts.find((t) => t.langCode === lang.langCode); + return ( +
+ + {lang.langName} + + handleTextChange(lang.langCode, e.target.value)} + className="flex-1" + /> +
+ ); + })} +
+ {/* 저장 버튼 - 고정 위치 */} +
+ + +
+
+ ) : ( +
+
+
언어 키를 선택하세요
+
좌측 목록에서 편집할 언어 키를 클릭하세요
+
+
+ )} +
+
+
+ )} +
+ + {/* 언어 키 추가/수정 모달 */} + setIsModalOpen(false)} + onSave={handleSaveKey} + keyData={editingKey} + companies={companies} + /> + + {/* 언어 추가/수정 모달 */} + setIsLanguageModalOpen(false)} + onSave={handleSaveLanguage} + languageData={editingLanguage} + /> +
); diff --git a/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx index 7854e6ee..a9cd747c 100644 --- a/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx +++ b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx @@ -1,12 +1,115 @@ "use client"; -import { useParams } from "next/navigation"; -import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement"; +import { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ArrowLeft } from "lucide-react"; +import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure"; +import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers"; +import type { Department } from "@/types/department"; +import { getCompanyList } from "@/lib/api/company"; +/** + * 부서 관리 메인 페이지 + * 좌측: 부서 구조, 우측: 부서 인원 + */ export default function DepartmentManagementPage() { const params = useParams(); + const router = useRouter(); const companyCode = params.companyCode as string; + const [selectedDepartment, setSelectedDepartment] = useState(null); + const [activeTab, setActiveTab] = useState("structure"); + const [companyName, setCompanyName] = useState(""); + const [refreshTrigger, setRefreshTrigger] = useState(0); - return ; + // 부서원 변경 시 부서 구조 새로고침 + const handleMemberChange = () => { + setRefreshTrigger((prev) => prev + 1); + }; + + // 회사 정보 로드 + useEffect(() => { + const loadCompanyInfo = async () => { + const response = await getCompanyList(); + if (response.success && response.data) { + const company = response.data.find((c) => c.company_code === companyCode); + if (company) { + setCompanyName(company.company_name); + } + } + }; + loadCompanyInfo(); + }, [companyCode]); + + const handleBackToList = () => { + router.push("/admin/userMng/companyList"); + }; + + return ( +
+ {/* 상단 헤더: 회사 정보 + 뒤로가기 */} +
+
+ +
+
+

{companyName || companyCode}

+

부서 관리

+
+
+
+ {/* 탭 네비게이션 (모바일용) */} +
+ + + 부서 구조 + 부서 인원 + + + + + + + + + + +
+ + {/* 좌우 레이아웃 (데스크톱) */} +
+ {/* 좌측: 부서 구조 (20%) */} +
+ +
+ + {/* 우측: 부서 인원 (80%) */} +
+ +
+
+
+ ); } - diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx index c24afc7a..a36cd9c3 100644 --- a/frontend/app/(main)/admin/userMng/companyList/page.tsx +++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx @@ -1,10 +1,56 @@ -import { CompanyManagement } from "@/components/admin/CompanyManagement"; +"use client"; + +import { useCompanyManagement } from "@/hooks/useCompanyManagement"; +import { CompanyToolbar } from "@/components/admin/CompanyToolbar"; +import { CompanyTable } from "@/components/admin/CompanyTable"; +import { CompanyFormModal } from "@/components/admin/CompanyFormModal"; +import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog"; +import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary"; import { ScrollToTop } from "@/components/common/ScrollToTop"; /** * 회사 관리 페이지 + * 모든 회사 관리 기능을 통합하여 제공 */ export default function CompanyPage() { + const { + // 데이터 + companies, + searchFilter, + isLoading, + error, + + // 디스크 사용량 관련 + diskUsageInfo, + isDiskUsageLoading, + loadDiskUsage, + + // 모달 상태 + modalState, + deleteState, + + // 검색 기능 + updateSearchFilter, + clearSearchFilter, + + // 모달 제어 + openCreateModal, + openEditModal, + closeModal, + updateFormData, + + // 삭제 다이얼로그 제어 + openDeleteDialog, + closeDeleteDialog, + + // CRUD 작업 + saveCompany, + deleteCompany, + + // 에러 처리 + clearError, + } = useCompanyManagement(); + return (
@@ -14,8 +60,42 @@ export default function CompanyPage() {

시스템에서 사용하는 회사 정보를 관리합니다

- {/* 메인 컨텐츠 */} - + {/* 디스크 사용량 요약 */} + + + {/* 툴바 - 검색, 필터, 등록 버튼 */} + + + {/* 회사 목록 테이블 */} + + + {/* 회사 등록/수정 모달 */} + + + {/* 회사 삭제 확인 다이얼로그 */} +
{/* Scroll to Top 버튼 */} diff --git a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx index a1579bf2..30552af4 100644 --- a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx +++ b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx @@ -1,12 +1,20 @@ "use client"; +import React, { useState, useCallback, useEffect } from "react"; import { use } from "react"; -import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react"; +import { roleAPI, RoleGroup } from "@/lib/api/role"; +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { DualListBox } from "@/components/common/DualListBox"; +import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable"; +import { useMenu } from "@/contexts/MenuContext"; import { ScrollToTop } from "@/components/common/ScrollToTop"; /** * 권한 그룹 상세 페이지 - * URL: /admin/roles/[id] + * URL: /admin/userMng/rolesList/[id] * * 기능: * - 권한 그룹 멤버 관리 (Dual List Box) @@ -14,13 +22,324 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; */ export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) { // Next.js 15: params는 Promise이므로 React.use()로 unwrap - const { id } = use(params); + const { id: roleId } = use(params); + const { user: currentUser } = useAuth(); + const router = useRouter(); + const { refreshMenus } = useMenu(); + + const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; + + // 상태 관리 + const [roleGroup, setRoleGroup] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 탭 상태 + const [activeTab, setActiveTab] = useState<"members" | "permissions">("members"); + + // 멤버 관리 상태 + const [availableUsers, setAvailableUsers] = useState>([]); + const [selectedUsers, setSelectedUsers] = useState>([]); + const [isSavingMembers, setIsSavingMembers] = useState(false); + + // 메뉴 권한 상태 + const [menuPermissions, setMenuPermissions] = useState([]); + const [isSavingPermissions, setIsSavingPermissions] = useState(false); + + // 데이터 로드 + const loadRoleGroup = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await roleAPI.getById(parseInt(roleId, 10)); + + if (response.success && response.data) { + setRoleGroup(response.data); + } else { + setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다."); + } + } catch (err) { + console.error("권한 그룹 정보 로드 오류:", err); + setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, [roleId]); + + // 멤버 목록 로드 + const loadMembers = useCallback(async () => { + if (!roleGroup) return; + + try { + // 1. 권한 그룹 멤버 조회 + const membersResponse = await roleAPI.getMembers(roleGroup.objid); + if (membersResponse.success && membersResponse.data) { + setSelectedUsers( + membersResponse.data.map((member: any) => ({ + id: member.userId, + label: member.userName || member.userId, + description: member.deptName, + })), + ); + } + + // 2. 전체 사용자 목록 조회 (같은 회사) + const userAPI = await import("@/lib/api/user"); + + console.log("🔍 사용자 목록 조회 요청:", { + companyCode: roleGroup.companyCode, + size: 1000, + }); + + const usersResponse = await userAPI.userAPI.getList({ + companyCode: roleGroup.companyCode, + size: 1000, // 대량 조회 + }); + + console.log("✅ 사용자 목록 응답:", { + success: usersResponse.success, + count: usersResponse.data?.length, + total: usersResponse.total, + }); + + if (usersResponse.success && usersResponse.data) { + setAvailableUsers( + usersResponse.data.map((user: any) => ({ + id: user.userId, + label: user.userName || user.userId, + description: user.deptName, + })), + ); + console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length); + } + } catch (err) { + console.error("멤버 목록 로드 오류:", err); + } + }, [roleGroup]); + + // 메뉴 권한 로드 + const loadMenuPermissions = useCallback(async () => { + if (!roleGroup) return; + + console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", { + roleGroupId: roleGroup.objid, + roleGroupName: roleGroup.authName, + companyCode: roleGroup.companyCode, + }); + + try { + const response = await roleAPI.getMenuPermissions(roleGroup.objid); + + console.log("✅ [loadMenuPermissions] API 응답", { + success: response.success, + dataCount: response.data?.length, + data: response.data, + }); + + if (response.success && response.data) { + setMenuPermissions(response.data); + console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", { + count: response.data.length, + }); + } else { + console.warn("⚠️ [loadMenuPermissions] 응답 실패", { + message: response.message, + }); + } + } catch (err) { + console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err); + } + }, [roleGroup]); + + useEffect(() => { + loadRoleGroup(); + }, [loadRoleGroup]); + + useEffect(() => { + if (roleGroup && activeTab === "members") { + loadMembers(); + } else if (roleGroup && activeTab === "permissions") { + loadMenuPermissions(); + } + }, [roleGroup, activeTab, loadMembers, loadMenuPermissions]); + + // 멤버 저장 핸들러 + const handleSaveMembers = useCallback(async () => { + if (!roleGroup) return; + + setIsSavingMembers(true); + try { + // 현재 선택된 사용자 ID 목록 + const selectedUserIds = selectedUsers.map((user) => user.id); + + // 멤버 업데이트 API 호출 + const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds); + + if (response.success) { + alert("멤버가 성공적으로 저장되었습니다."); + loadMembers(); // 새로고침 + + // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음) + await refreshMenus(); + } else { + alert(response.message || "멤버 저장에 실패했습니다."); + } + } catch (err) { + console.error("멤버 저장 오류:", err); + alert("멤버 저장 중 오류가 발생했습니다."); + } finally { + setIsSavingMembers(false); + } + }, [roleGroup, selectedUsers, loadMembers, refreshMenus]); + + // 메뉴 권한 저장 핸들러 + const handleSavePermissions = useCallback(async () => { + if (!roleGroup) return; + + setIsSavingPermissions(true); + try { + const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions); + + if (response.success) { + alert("메뉴 권한이 성공적으로 저장되었습니다."); + loadMenuPermissions(); // 새로고침 + + // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영) + await refreshMenus(); + } else { + alert(response.message || "메뉴 권한 저장에 실패했습니다."); + } + } catch (err) { + console.error("메뉴 권한 저장 오류:", err); + alert("메뉴 권한 저장 중 오류가 발생했습니다."); + } finally { + setIsSavingPermissions(false); + } + }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]); + + if (isLoading) { + return ( +
+
+
+

권한 그룹 정보를 불러오는 중...

+
+
+ ); + } + + if (error || !roleGroup) { + return ( +
+ +

오류 발생

+

{error || "권한 그룹을 찾을 수 없습니다."}

+ +
+ ); + } return (
- {/* 메인 컨텐츠 */} - + {/* 페이지 헤더 */} +
+
+ +
+

{roleGroup.authName}

+

+ {roleGroup.authCode} • {roleGroup.companyCode} +

+
+ + {roleGroup.status === "active" ? "활성" : "비활성"} + +
+
+ + {/* 탭 네비게이션 */} +
+ + +
+ + {/* 탭 컨텐츠 */} +
+ {activeTab === "members" && ( + <> +
+
+

멤버 관리

+

이 권한 그룹에 속한 사용자를 관리합니다

+
+ +
+ + + + )} + + {activeTab === "permissions" && ( + <> +
+
+

메뉴 권한 설정

+

이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다

+
+ +
+ + + + )} +
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */} diff --git a/frontend/app/(main)/admin/userMng/rolesList/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/page.tsx index 2b973ad5..eeac1dec 100644 --- a/frontend/app/(main)/admin/userMng/rolesList/page.tsx +++ b/frontend/app/(main)/admin/userMng/rolesList/page.tsx @@ -1,6 +1,16 @@ "use client"; -import { RoleManagement } from "@/components/admin/RoleManagement"; +import React, { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react"; +import { roleAPI, RoleGroup } from "@/lib/api/role"; +import { useAuth } from "@/hooks/useAuth"; +import { AlertCircle } from "lucide-react"; +import { RoleFormModal } from "@/components/admin/RoleFormModal"; +import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal"; +import { useRouter } from "next/navigation"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { companyAPI } from "@/lib/api/company"; import { ScrollToTop } from "@/components/common/ScrollToTop"; /** @@ -14,21 +24,336 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; * - 권한 그룹 생성/수정/삭제 * - 멤버 관리 (Dual List Box) * - 메뉴 권한 설정 (CRUD 권한) + * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정) */ export default function RolesPage() { + const { user: currentUser } = useAuth(); + const router = useRouter(); + + // 회사 관리자 또는 최고 관리자 여부 + const isAdmin = + (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") || + currentUser?.userType === "COMPANY_ADMIN"; + const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; + + // 상태 관리 + const [roleGroups, setRoleGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 회사 필터 (최고 관리자 전용) + const [companies, setCompanies] = useState>([]); + const [selectedCompany, setSelectedCompany] = useState("all"); + + // 모달 상태 + const [formModal, setFormModal] = useState({ + isOpen: false, + editingRole: null as RoleGroup | null, + }); + + const [deleteModal, setDeleteModal] = useState({ + isOpen: false, + role: null as RoleGroup | null, + }); + + // 회사 목록 로드 (최고 관리자만) + const loadCompanies = useCallback(async () => { + if (!isSuperAdmin) return; + + try { + const companies = await companyAPI.getList(); + setCompanies(companies); + } catch (error) { + console.error("회사 목록 로드 오류:", error); + } + }, [isSuperAdmin]); + + // 데이터 로드 + const loadRoleGroups = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회) + // 회사 관리자: 자기 회사만 조회 + const companyFilter = + isSuperAdmin && selectedCompany !== "all" + ? selectedCompany + : isSuperAdmin + ? undefined + : currentUser?.companyCode; + + console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter }); + + const response = await roleAPI.getList({ + companyCode: companyFilter, + }); + + if (response.success && response.data) { + setRoleGroups(response.data); + console.log("권한 그룹 조회 성공:", response.data.length, "개"); + } else { + setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다."); + } + } catch (err) { + console.error("권한 그룹 목록 로드 오류:", err); + setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, [isSuperAdmin, selectedCompany, currentUser?.companyCode]); + + useEffect(() => { + if (isAdmin) { + if (isSuperAdmin) { + loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드 + } + loadRoleGroups(); + } else { + setIsLoading(false); + } + }, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]); + + // 권한 그룹 생성 핸들러 + const handleCreateRole = useCallback(() => { + setFormModal({ isOpen: true, editingRole: null }); + }, []); + + // 권한 그룹 수정 핸들러 + const handleEditRole = useCallback((role: RoleGroup) => { + setFormModal({ isOpen: true, editingRole: role }); + }, []); + + // 권한 그룹 삭제 핸들러 + const handleDeleteRole = useCallback((role: RoleGroup) => { + setDeleteModal({ isOpen: true, role }); + }, []); + + // 폼 모달 닫기 + const handleFormModalClose = useCallback(() => { + setFormModal({ isOpen: false, editingRole: null }); + }, []); + + // 삭제 모달 닫기 + const handleDeleteModalClose = useCallback(() => { + setDeleteModal({ isOpen: false, role: null }); + }, []); + + // 모달 성공 후 새로고침 + const handleModalSuccess = useCallback(() => { + loadRoleGroups(); + }, [loadRoleGroups]); + + // 상세 페이지로 이동 + const handleViewDetail = useCallback( + (role: RoleGroup) => { + router.push(`/admin/userMng/rolesList/${role.objid}`); + }, + [router], + ); + + // 관리자가 아니면 접근 제한 + if (!isAdmin) { + return ( +
+
+
+

권한 그룹 관리

+

회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)

+
+ +
+ +

접근 권한 없음

+

+ 권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다. +

+ +
+
+ + +
+ ); + } + return (
{/* 페이지 헤더 */}

권한 그룹 관리

-

- 회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상) -

+

회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)

- {/* 메인 컨텐츠 */} - + {/* 에러 메시지 */} + {error && ( +
+
+

오류가 발생했습니다

+ +
+

{error}

+
+ )} + + {/* 액션 버튼 영역 */} +
+
+

권한 그룹 목록 ({roleGroups.length})

+ + {/* 최고 관리자 전용: 회사 필터 */} + {isSuperAdmin && ( +
+ + + {selectedCompany !== "all" && ( + + )} +
+ )} +
+ + +
+ + {/* 권한 그룹 목록 */} + {isLoading ? ( +
+
+
+

권한 그룹 목록을 불러오는 중...

+
+
+ ) : roleGroups.length === 0 ? ( +
+
+

등록된 권한 그룹이 없습니다.

+

권한 그룹을 생성하여 멤버를 관리해보세요.

+
+
+ ) : ( +
+ {roleGroups.map((role) => ( +
+ {/* 헤더 (클릭 시 상세 페이지) */} +
handleViewDetail(role)} + > +
+
+

{role.authName}

+

{role.authCode}

+
+ + {role.status === "active" ? "활성" : "비활성"} + +
+ + {/* 정보 */} +
+ {/* 최고 관리자는 회사명 표시 */} + {isSuperAdmin && ( +
+ 회사 + + {companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode} + +
+ )} +
+ + + 멤버 수 + + {role.memberCount || 0}명 +
+
+ + + 메뉴 권한 + + {role.menuCount || 0}개 +
+
+
+ + {/* 액션 버튼 */} +
+ + +
+
+ ))} +
+ )} + + {/* 모달들 */} + + +
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */} diff --git a/frontend/app/(main)/admin/userMng/userAuthList/page.tsx b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx index 322bba64..4ad69183 100644 --- a/frontend/app/(main)/admin/userMng/userAuthList/page.tsx +++ b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx @@ -1,6 +1,12 @@ "use client"; -import { UserAuthManagement } from "@/components/admin/UserAuthManagement"; +import React, { useState, useCallback, useEffect } from "react"; +import { UserAuthTable } from "@/components/admin/UserAuthTable"; +import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal"; +import { userAPI } from "@/lib/api/user"; +import { useAuth } from "@/hooks/useAuth"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { ScrollToTop } from "@/components/common/ScrollToTop"; /** @@ -11,6 +17,119 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; * 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리 */ export default function UserAuthPage() { + const { user: currentUser } = useAuth(); + + // 최고 관리자 여부 + const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; + + // 상태 관리 + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [paginationInfo, setPaginationInfo] = useState({ + currentPage: 1, + pageSize: 20, + totalItems: 0, + totalPages: 0, + }); + + // 권한 변경 모달 + const [authEditModal, setAuthEditModal] = useState({ + isOpen: false, + user: null as any | null, + }); + + // 데이터 로드 + const loadUsers = useCallback( + async (page: number = 1) => { + setIsLoading(true); + setError(null); + + try { + const response = await userAPI.getList({ + page, + size: paginationInfo.pageSize, + }); + + if (response.success && response.data) { + setUsers(response.data); + setPaginationInfo({ + currentPage: response.currentPage || page, + pageSize: response.pageSize || paginationInfo.pageSize, + totalItems: response.total || 0, + totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)), + }); + } else { + setError(response.message || "사용자 목록을 불러오는데 실패했습니다."); + } + } catch (err) { + console.error("사용자 목록 로드 오류:", err); + setError("사용자 목록을 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, + [paginationInfo.pageSize], + ); + + useEffect(() => { + loadUsers(1); + }, []); + + // 권한 변경 핸들러 + const handleEditAuth = (user: any) => { + setAuthEditModal({ + isOpen: true, + user, + }); + }; + + // 권한 변경 모달 닫기 + const handleAuthEditClose = () => { + setAuthEditModal({ + isOpen: false, + user: null, + }); + }; + + // 권한 변경 성공 + const handleAuthEditSuccess = () => { + loadUsers(paginationInfo.currentPage); + handleAuthEditClose(); + }; + + // 페이지 변경 + const handlePageChange = (page: number) => { + loadUsers(page); + }; + + // 최고 관리자가 아닌 경우 + if (!isSuperAdmin) { + return ( +
+
+
+

사용자 권한 관리

+

사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)

+
+ +
+ +

접근 권한 없음

+

+ 권한 관리는 최고 관리자만 접근할 수 있습니다. +

+ +
+
+ + +
+ ); + } + return (
@@ -20,8 +139,39 @@ export default function UserAuthPage() {

사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)

- {/* 메인 컨텐츠 */} - + {/* 에러 메시지 */} + {error && ( +
+
+

오류가 발생했습니다

+ +
+

{error}

+
+ )} + + {/* 사용자 권한 테이블 */} + + + {/* 권한 변경 모달 */} +
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */} diff --git a/frontend/app/(main)/admin/userMng/userMngList/page.tsx b/frontend/app/(main)/admin/userMng/userMngList/page.tsx index 428e8986..828390d9 100644 --- a/frontend/app/(main)/admin/userMng/userMngList/page.tsx +++ b/frontend/app/(main)/admin/userMng/userMngList/page.tsx @@ -1,6 +1,12 @@ "use client"; -import { UserManagement } from "@/components/admin/UserManagement"; +import { useState } from "react"; +import { useUserManagement } from "@/hooks/useUserManagement"; +import { UserToolbar } from "@/components/admin/UserToolbar"; +import { UserTable } from "@/components/admin/UserTable"; +import { Pagination } from "@/components/common/Pagination"; +import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal"; +import { UserFormModal } from "@/components/admin/UserFormModal"; import { ScrollToTop } from "@/components/common/ScrollToTop"; /** @@ -8,8 +14,100 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; * URL: /admin/userMng * * shadcn/ui 스타일 가이드 적용 + * - 원본 Spring + JSP 코드 패턴 기반 REST API 연동 + * - 실제 데이터베이스와 연동되어 작동 */ export default function UserMngPage() { + const { + // 데이터 + users, + searchFilter, + isLoading, + isSearching, + error, + paginationInfo, + + // 검색 기능 + updateSearchFilter, + + // 페이지네이션 + handlePageChange, + handlePageSizeChange, + + // 액션 핸들러 + handleStatusToggle, + + // 유틸리티 + clearError, + refreshData, + } = useUserManagement(); + + // 비밀번호 초기화 모달 상태 + const [passwordResetModal, setPasswordResetModal] = useState({ + isOpen: false, + userId: null as string | null, + userName: null as string | null, + }); + + // 사용자 등록/수정 모달 상태 + const [userFormModal, setUserFormModal] = useState({ + isOpen: false, + editingUser: null as any | null, + }); + + // 사용자 등록 핸들러 + const handleCreateUser = () => { + setUserFormModal({ + isOpen: true, + editingUser: null, + }); + }; + + // 사용자 수정 핸들러 + const handleEditUser = (user: any) => { + setUserFormModal({ + isOpen: true, + editingUser: user, + }); + }; + + // 사용자 등록/수정 모달 닫기 + const handleUserFormClose = () => { + setUserFormModal({ + isOpen: false, + editingUser: null, + }); + }; + + // 사용자 등록/수정 성공 핸들러 + const handleUserFormSuccess = () => { + refreshData(); + handleUserFormClose(); + }; + + // 비밀번호 초기화 핸들러 + const handlePasswordReset = (userId: string, userName: string) => { + setPasswordResetModal({ + isOpen: true, + userId, + userName, + }); + }; + + // 비밀번호 초기화 모달 닫기 + const handlePasswordResetClose = () => { + setPasswordResetModal({ + isOpen: false, + userId: null, + userName: null, + }); + }; + + // 비밀번호 초기화 성공 핸들러 + const handlePasswordResetSuccess = () => { + handlePasswordResetClose(); + }; + return (
@@ -19,8 +117,70 @@ export default function UserMngPage() {

시스템 사용자 계정 및 권한을 관리합니다

- {/* 메인 컨텐츠 */} - + {/* 툴바 - 검색, 필터, 등록 버튼 */} + + + {/* 에러 메시지 */} + {error && ( +
+
+

오류가 발생했습니다

+ +
+

{error}

+
+ )} + + {/* 사용자 목록 테이블 */} + + + {/* 페이지네이션 */} + {!isLoading && users.length > 0 && ( + + )} + + {/* 사용자 등록/수정 모달 */} + + + {/* 비밀번호 초기화 모달 */} +
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */} diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 00ef509b..56558f7e 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge"; export default function MainPage() { return (
- {/* 메인 컨텐츠 */} {/* Welcome Message */} diff --git a/frontend/components/admin/CompanyManagement.tsx b/frontend/components/admin/CompanyManagement.tsx deleted file mode 100644 index 4e88e35a..00000000 --- a/frontend/components/admin/CompanyManagement.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { useCompanyManagement } from "@/hooks/useCompanyManagement"; -import { CompanyToolbar } from "./CompanyToolbar"; -import { CompanyTable } from "./CompanyTable"; -import { CompanyFormModal } from "./CompanyFormModal"; -import { CompanyDeleteDialog } from "./CompanyDeleteDialog"; -import { DiskUsageSummary } from "./DiskUsageSummary"; - -/** - * 회사 관리 메인 컴포넌트 - * 모든 회사 관리 기능을 통합하여 제공 - */ -export function CompanyManagement() { - const { - // 데이터 - companies, - searchFilter, - isLoading, - error, - - // 디스크 사용량 관련 - diskUsageInfo, - isDiskUsageLoading, - loadDiskUsage, - - // 모달 상태 - modalState, - deleteState, - - // 검색 기능 - updateSearchFilter, - clearSearchFilter, - - // 모달 제어 - openCreateModal, - openEditModal, - closeModal, - updateFormData, - - // 삭제 다이얼로그 제어 - openDeleteDialog, - closeDeleteDialog, - - // CRUD 작업 - saveCompany, - deleteCompany, - - // 에러 처리 - clearError, - } = useCompanyManagement(); - - return ( -
- {/* 디스크 사용량 요약 */} - - - {/* 툴바 - 검색, 필터, 등록 버튼 */} - - - {/* 회사 목록 테이블 */} - - - {/* 회사 등록/수정 모달 */} - - - {/* 회사 삭제 확인 다이얼로그 */} - -
- ); -} diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx new file mode 100644 index 00000000..3d53accc --- /dev/null +++ b/frontend/components/admin/CompanySwitcher.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Building2, Search } from "lucide-react"; +import { useAuth } from "@/hooks/useAuth"; +import { apiClient } from "@/lib/api/client"; +import { logger } from "@/lib/utils/logger"; + +interface Company { + company_code: string; + company_name: string; + status: string; +} + +interface CompanySwitcherProps { + onClose?: () => void; + isOpen?: boolean; // Dialog 열림 상태 (AppLayout에서 전달) +} + +/** + * WACE 관리자 전용: 회사 선택 및 전환 컴포넌트 + * + * - WACE 관리자(company_code = "*", userType = "SUPER_ADMIN")만 표시 + * - 회사 선택 시 해당 회사로 전환하여 시스템 사용 + * - JWT 토큰 재발급으로 company_code 변경 + */ +export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProps = {}) { + const { user, switchCompany } = useAuth(); + const [companies, setCompanies] = useState([]); + const [filteredCompanies, setFilteredCompanies] = useState([]); + const [searchText, setSearchText] = useState(""); + const [loading, setLoading] = useState(false); + + // WACE 관리자 권한 체크 (userType만 확인) + const isWaceAdmin = user?.userType === "SUPER_ADMIN"; + + // 현재 선택된 회사명 표시 + const currentCompanyName = React.useMemo(() => { + if (!user?.companyCode) return "로딩 중..."; + + if (user.companyCode === "*") { + return "WACE (최고 관리자)"; + } + + // companies 배열에서 현재 회사 찾기 + const currentCompany = companies.find(c => c.company_code === user.companyCode); + return currentCompany?.company_name || user.companyCode; + }, [user?.companyCode, companies]); + + // 회사 목록 조회 + useEffect(() => { + if (isWaceAdmin && isOpen) { + fetchCompanies(); + } + }, [isWaceAdmin, isOpen]); + + // 검색 필터링 + useEffect(() => { + if (searchText.trim() === "") { + setFilteredCompanies(companies); + } else { + const filtered = companies.filter(company => + company.company_name.toLowerCase().includes(searchText.toLowerCase()) || + company.company_code.toLowerCase().includes(searchText.toLowerCase()) + ); + setFilteredCompanies(filtered); + } + }, [searchText, companies]); + + const fetchCompanies = async () => { + try { + setLoading(true); + const response = await apiClient.get("/admin/companies/db"); + + if (response.data.success) { + // 활성 상태의 회사만 필터링 + company_code="*" 제외 (WACE는 별도 추가) + const activeCompanies = response.data.data + .filter((c: Company) => c.company_code !== "*") // DB의 "*" 제외 + .filter((c: Company) => c.status === "active" || !c.status) + .sort((a: Company, b: Company) => a.company_name.localeCompare(b.company_name)); + + // WACE 복귀 옵션 추가 + const companiesWithWace: Company[] = [ + { + company_code: "*", + company_name: "WACE (최고 관리자)", + status: "active", + }, + ...activeCompanies, + ]; + + setCompanies(companiesWithWace); + setFilteredCompanies(companiesWithWace); + } + } catch (error) { + logger.error("회사 목록 조회 실패", error); + } finally { + setLoading(false); + } + }; + + const handleCompanySwitch = async (companyCode: string) => { + try { + setLoading(true); + + const result = await switchCompany(companyCode); + + if (!result.success) { + alert(result.message || "회사 전환에 실패했습니다."); + setLoading(false); + return; + } + + logger.info("회사 전환 성공", { companyCode }); + + // 즉시 페이지 새로고침 (토큰이 이미 저장됨) + window.location.reload(); + } catch (error: any) { + logger.error("회사 전환 실패", error); + alert(error.message || "회사 전환 중 오류가 발생했습니다."); + setLoading(false); + } + }; + + // WACE 관리자가 아니면 렌더링하지 않음 + if (!isWaceAdmin) { + return null; + } + + return ( +
+ {/* 현재 회사 정보 */} +
+
+
+ +
+
+

현재 관리 회사

+

{currentCompanyName}

+
+
+
+ + {/* 회사 검색 */} +
+ + setSearchText(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+ + {/* 회사 목록 */} +
+ {loading ? ( +
+ 로딩 중... +
+ ) : filteredCompanies.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredCompanies.map((company) => ( +
handleCompanySwitch(company.company_code)} + > +
+ {company.company_name} + + {company.company_code} + +
+ {company.company_code === user?.companyCode && ( + 현재 + )} +
+ )) + )} +
+
+ ); +} + diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx deleted file mode 100644 index 67e8bab6..00000000 --- a/frontend/components/admin/MenuManagement.tsx +++ /dev/null @@ -1,1136 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useMemo } from "react"; -import { menuApi } from "@/lib/api/menu"; -import type { MenuItem } from "@/lib/api/menu"; -import { MenuTable } from "./MenuTable"; -import { MenuFormModal } from "./MenuFormModal"; -import { MenuCopyDialog } from "./MenuCopyDialog"; -import { Button } from "@/components/ui/button"; -import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { useMenu } from "@/contexts/MenuContext"; -import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang"; -import { useMultiLang } from "@/hooks/useMultiLang"; -import { apiClient } from "@/lib/api/client"; -import { useAuth } from "@/hooks/useAuth"; // useAuth 추가 - -type MenuType = "admin" | "user"; - -export const MenuManagement: React.FC = () => { - const { adminMenus, userMenus, refreshMenus } = useMenu(); - const { user } = useAuth(); // 현재 사용자 정보 가져오기 - const [selectedMenuType, setSelectedMenuType] = useState("admin"); - const [loading, setLoading] = useState(false); - const [deleting, setDeleting] = useState(false); - const [formModalOpen, setFormModalOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [copyDialogOpen, setCopyDialogOpen] = useState(false); - const [selectedMenuId, setSelectedMenuId] = useState(""); - const [selectedMenuName, setSelectedMenuName] = useState(""); - const [selectedMenus, setSelectedMenus] = useState>(new Set()); - - // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시) - const [localAdminMenus, setLocalAdminMenus] = useState([]); - const [localUserMenus, setLocalUserMenus] = useState([]); - - // 다국어 텍스트 훅 사용 - // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용 - const { userLang } = useMultiLang({ companyCode: "*" }); - - // SUPER_ADMIN 여부 확인 - const isSuperAdmin = user?.userType === "SUPER_ADMIN"; - - // 다국어 텍스트 상태 - const [uiTexts, setUiTexts] = useState>({}); - const [uiTextsLoading, setUiTextsLoading] = useState(false); - - // 회사 목록 상태 - const [companies, setCompanies] = useState>([]); - const [selectedCompany, setSelectedCompany] = useState("all"); - const [searchText, setSearchText] = useState(""); - const [expandedMenus, setExpandedMenus] = useState>(new Set()); - const [companySearchText, setCompanySearchText] = useState(""); - const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false); - const [formData, setFormData] = useState({ - menuId: "", - parentId: "", - menuType: "", - level: 0, - parentCompanyCode: "", - }); - - // 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴 - - // 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들) - const MENU_MANAGEMENT_LANG_KEYS = [ - // 페이지 제목 및 설명 - "menu.management.title", - "menu.management.description", - "menu.type.title", - "menu.type.admin", - "menu.type.user", - "menu.management.admin", - "menu.management.user", - "menu.management.admin.description", - "menu.management.user.description", - - // 버튼 - "button.add", - "button.add.top.level", - "button.add.sub", - "button.edit", - "button.delete", - "button.delete.selected", - "button.delete.selected.count", - "button.delete.processing", - "button.cancel", - "button.save", - "button.register", - "button.modify", - - // 필터 및 검색 - "filter.company", - "filter.company.all", - "filter.company.common", - "filter.company.search", - "filter.search", - "filter.search.placeholder", - "filter.reset", - - // 테이블 헤더 - "table.header.select", - "table.header.menu.name", - "table.header.menu.url", - "table.header.menu.type", - "table.header.status", - "table.header.company", - "table.header.sequence", - "table.header.actions", - - // 상태 - "status.active", - "status.inactive", - "status.unspecified", - - // 폼 - "form.menu.type", - "form.menu.type.admin", - "form.menu.type.user", - "form.company", - "form.company.select", - "form.company.common", - "form.company.submenu.note", - "form.lang.key", - "form.lang.key.select", - "form.lang.key.none", - "form.lang.key.search", - "form.lang.key.selected", - "form.menu.name", - "form.menu.name.placeholder", - "form.menu.url", - "form.menu.url.placeholder", - "form.menu.description", - "form.menu.description.placeholder", - "form.menu.sequence", - - // 모달 - "modal.menu.register.title", - "modal.menu.modify.title", - "modal.delete.title", - "modal.delete.description", - "modal.delete.batch.description", - - // 메시지 - "message.loading", - "message.menu.delete.processing", - "message.menu.save.success", - "message.menu.save.failed", - "message.menu.delete.success", - "message.menu.delete.failed", - "message.menu.delete.batch.success", - "message.menu.delete.batch.partial", - "message.menu.status.toggle.success", - "message.menu.status.toggle.failed", - "message.validation.menu.name.required", - "message.validation.company.required", - "message.validation.select.menu.delete", - "message.error.load.menu.list", - "message.error.load.menu.info", - "message.error.load.company.list", - "message.error.load.lang.key.list", - - // 리스트 정보 - "menu.list.title", - "menu.list.total", - "menu.list.search.result", - - // UI - "ui.expand", - "ui.collapse", - "ui.menu.collapse", - "ui.language", - ]; - - // 초기 로딩 - useEffect(() => { - loadCompanies(); - loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시) - // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정 - if (!userLang) { - initializeDefaultTexts(); - } - }, [userLang]); // userLang 변경 시마다 실행 - - // 초기 기본 텍스트 설정 함수 - const initializeDefaultTexts = () => { - const defaultTexts: Record = {}; - MENU_MANAGEMENT_LANG_KEYS.forEach((key) => { - // 기본 한국어 텍스트 제공 - const defaultText = getDefaultText(key); - defaultTexts[key] = defaultText; - }); - setUiTexts(defaultTexts); - // console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length); - }; - - // 기본 텍스트 반환 함수 - const getDefaultText = (key: string): string => { - const defaultTexts: Record = { - "menu.management.title": "메뉴 관리", - "menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.", - "menu.type.title": "메뉴 타입", - "menu.type.admin": "관리자", - "menu.type.user": "사용자", - "menu.management.admin": "관리자 메뉴", - "menu.management.user": "사용자 메뉴", - "menu.management.admin.description": "시스템 관리 및 설정 메뉴", - "menu.management.user.description": "일반 사용자 업무 메뉴", - "button.add": "추가", - "button.add.top.level": "최상위 메뉴 추가", - "button.add.sub": "하위 메뉴 추가", - "button.edit": "수정", - "button.delete": "삭제", - "button.delete.selected": "선택 삭제", - "button.delete.selected.count": "선택 삭제 ({count})", - "button.delete.processing": "삭제 중...", - "button.cancel": "취소", - "button.save": "저장", - "button.register": "등록", - "button.modify": "수정", - "filter.company": "회사", - "filter.company.all": "전체", - "filter.company.common": "공통", - "filter.company.search": "회사 검색", - "filter.search": "검색", - "filter.search.placeholder": "메뉴명 또는 URL로 검색...", - "filter.reset": "초기화", - "table.header.select": "선택", - "table.header.menu.name": "메뉴명", - "table.header.menu.url": "URL", - "table.header.menu.type": "메뉴 타입", - "table.header.status": "상태", - "table.header.company": "회사", - "table.header.sequence": "순서", - "table.header.actions": "작업", - "status.active": "활성화", - "status.inactive": "비활성화", - "status.unspecified": "미지정", - "form.menu.type": "메뉴 타입", - "form.menu.type.admin": "관리자", - "form.menu.type.user": "사용자", - "form.company": "회사", - "form.company.select": "회사를 선택하세요", - "form.company.common": "공통", - "form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.", - "form.lang.key": "다국어 키", - "form.lang.key.select": "다국어 키를 선택하세요", - "form.lang.key.none": "다국어 키 없음", - "form.lang.key.search": "다국어 키 검색...", - "form.lang.key.selected": "선택된 키: {key} - {description}", - "form.menu.name": "메뉴명", - "form.menu.name.placeholder": "메뉴명을 입력하세요", - "form.menu.url": "URL", - "form.menu.url.placeholder": "메뉴 URL을 입력하세요", - "form.menu.description": "설명", - "form.menu.description.placeholder": "메뉴 설명을 입력하세요", - "form.menu.sequence": "순서", - "modal.menu.register.title": "메뉴 등록", - "modal.menu.modify.title": "메뉴 수정", - "modal.delete.title": "메뉴 삭제", - "modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", - "modal.delete.batch.description": - "선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠️ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.", - "message.loading": "로딩 중...", - "message.menu.delete.processing": "메뉴 삭제 중...", - "message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.", - "message.menu.save.failed": "메뉴 저장에 실패했습니다.", - "message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.", - "message.menu.delete.failed": "메뉴 삭제에 실패했습니다.", - "message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.", - "message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.", - "message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.", - "message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.", - "message.validation.menu.name.required": "메뉴명을 입력해주세요.", - "message.validation.company.required": "회사를 선택해주세요.", - "message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.", - "message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.", - "message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.", - "message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.", - "message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.", - "menu.list.title": "메뉴 목록", - "menu.list.total": "총 {count}개", - "menu.list.search.result": "검색 결과: {count}개", - "ui.expand": "펼치기", - "ui.collapse": "접기", - "ui.menu.collapse": "메뉴 접기", - "ui.language": "언어", - }; - - return defaultTexts[key] || key; - }; - - // 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드 - useEffect(() => { - if (userLang && !uiTextsLoading) { - loadUITexts(); - } - }, [userLang]); // userLang 변경 시마다 실행 - - // uiTexts 상태 변경 감지 - useEffect(() => { - // console.log("🔄 uiTexts 상태 변경됨:", { - // count: Object.keys(uiTexts).length, - // sampleKeys: Object.keys(uiTexts).slice(0, 5), - // sampleValues: Object.entries(uiTexts) - // .slice(0, 3) - // .map(([k, v]) => `${k}: ${v}`), - // }); - }, [uiTexts]); - - // 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음) - useEffect(() => { - const timer = setTimeout(() => { - if (userLang && !uiTextsLoading) { - // console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드"); - loadUITexts(); - } - }, 300); // 300ms 후 실행 - - return () => clearTimeout(timer); - }, [userLang]); // userLang이 설정된 후 실행 - - // 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드 - useEffect(() => { - const fallbackTimer = setTimeout(() => { - if (!uiTextsLoading && Object.keys(uiTexts).length === 0) { - // console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드"); - // 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정 - if (!userLang) { - initializeDefaultTexts(); - } else { - // 사용자 언어가 설정된 경우 다국어 텍스트 로드 - loadUITexts(); - } - } - }, 1000); // 1초 후 실행 - - return () => clearTimeout(fallbackTimer); - }, [userLang]); // userLang 변경 시마다 실행 - - // 번역 로드 이벤트 감지 - useEffect(() => { - const handleTranslationLoaded = (event: CustomEvent) => { - const { key, text, userLang: loadedLang } = event.detail; - if (loadedLang === userLang) { - setUiTexts((prev) => ({ ...prev, [key]: text })); - } - }; - - window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener); - - return () => { - window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener); - }; - }, [userLang]); - - // 드롭다운 외부 클릭 시 닫기 - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Element; - if (!target.closest(".company-dropdown")) { - setIsCompanyDropdownOpen(false); - setCompanySearchText(""); - } - }; - - if (isCompanyDropdownOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isCompanyDropdownOpen]); - - // 특정 메뉴 타입만 로드하는 함수 - const loadMenusForType = async (type: MenuType, showLoading = true) => { - try { - if (showLoading) { - setLoading(true); - } - - if (type === "admin") { - const adminResponse = await menuApi.getAdminMenusForManagement(); - if (adminResponse.success && adminResponse.data) { - setLocalAdminMenus(adminResponse.data); - } - } else { - const userResponse = await menuApi.getUserMenusForManagement(); - if (userResponse.success && userResponse.data) { - setLocalUserMenus(userResponse.data); - } - } - } catch (error) { - toast.error(getUITextSync("message.error.load.menu.list")); - } finally { - if (showLoading) { - setLoading(false); - } - } - }; - - const loadMenus = async (showLoading = true) => { - // console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`); - try { - if (showLoading) { - setLoading(true); - } - - // 선택된 메뉴 타입에 해당하는 메뉴만 로드 - if (selectedMenuType === "admin") { - const adminResponse = await menuApi.getAdminMenusForManagement(); - if (adminResponse.success && adminResponse.data) { - setLocalAdminMenus(adminResponse.data); - } - } else { - const userResponse = await menuApi.getUserMenusForManagement(); - if (userResponse.success && userResponse.data) { - setLocalUserMenus(userResponse.data); - } - } - - // 전역 메뉴 상태도 업데이트 (좌측 사이드바용) - await refreshMenus(); - // console.log("📋 메뉴 목록 조회 성공"); - } catch (error) { - // console.error("❌ 메뉴 목록 조회 실패:", error); - toast.error(getUITextSync("message.error.load.menu.list")); - } finally { - if (showLoading) { - setLoading(false); - } - } - }; - - // 회사 목록 조회 - const loadCompanies = async () => { - // console.log("🏢 회사 목록 조회 시작"); - try { - const response = await apiClient.get("/admin/companies"); - - if (response.data.success) { - // console.log("🏢 회사 목록 응답:", response.data); - const companyList = response.data.data.map((company: any) => ({ - code: company.company_code || company.companyCode, - name: company.company_name || company.companyName, - })); - // console.log("🏢 변환된 회사 목록:", companyList); - setCompanies(companyList); - } - } catch (error) { - // console.error("❌ 회사 목록 조회 실패:", error); - } - }; - - // 다국어 텍스트 로드 함수 - 배치 API 사용 - const loadUITexts = async () => { - if (uiTextsLoading) return; // 이미 로딩 중이면 중단 - - // userLang이 설정되지 않았으면 기본값 설정 - if (!userLang) { - // console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정"); - const defaultTexts: Record = {}; - MENU_MANAGEMENT_LANG_KEYS.forEach((key) => { - defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용 - }); - setUiTexts(defaultTexts); - return; - } - - // 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화 - if (Object.keys(uiTexts).length === 0) { - // console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화"); - const defaultTexts: Record = {}; - MENU_MANAGEMENT_LANG_KEYS.forEach((key) => { - defaultTexts[key] = getDefaultText(key); - }); - setUiTexts(defaultTexts); - } - - // console.log("🌐 UI 다국어 텍스트 로드 시작", { - // userLang, - // apiParams: { - // companyCode: "*", - // menuCode: "menu.management", - // userLang: userLang, - // }, - // }); - setUiTextsLoading(true); - - try { - // 배치 API를 사용하여 모든 다국어 키를 한 번에 조회 - const response = await apiClient.post( - "/multilang/batch", - { - langKeys: MENU_MANAGEMENT_LANG_KEYS, - companyCode: "*", // 모든 회사 - menuCode: "menu.management", // 메뉴관리 메뉴 - userLang: userLang, // body에 포함 - }, - { - params: {}, // query params는 비움 - }, - ); - - if (response.data.success) { - const translations = response.data.data; - // console.log("🌐 배치 다국어 텍스트 응답:", translations); - - // 번역 결과를 상태에 저장 (기존 uiTexts와 병합) - const mergedTranslations = { ...uiTexts, ...translations }; - // console.log("🔧 setUiTexts 호출 전:", { - // translationsCount: Object.keys(translations).length, - // mergedCount: Object.keys(mergedTranslations).length, - // }); - setUiTexts(mergedTranslations); - // console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations); - - // 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록) - setTranslationCache(userLang, mergedTranslations); - } else { - // console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message); - // API 실패 시에도 기존 uiTexts는 유지 - // console.log("🔄 API 실패로 인해 기존 uiTexts 유지"); - } - } catch (error) { - // console.error("❌ UI 다국어 텍스트 로드 실패:", error); - // API 실패 시에도 기존 uiTexts는 유지 - // console.log("🔄 API 실패로 인해 기존 uiTexts 유지"); - } finally { - setUiTextsLoading(false); - } - }; - - // UI 텍스트 가져오기 함수 (동기 버전만 사용) - // getUIText 함수는 제거 - getUITextSync만 사용 - - // 동기 버전 (DB에서 가져온 번역 텍스트 사용) - const getUITextSync = (key: string, params?: Record, fallback?: string): string => { - // uiTexts에서 번역 텍스트 찾기 - let text = uiTexts[key]; - - // uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기 - if (!text) { - text = getMenuTextSync(key, userLang) || fallback || key; - } - - // 파라미터 치환 - if (params && text) { - Object.entries(params).forEach(([paramKey, paramValue]) => { - text = text!.replace(`{${paramKey}}`, String(paramValue)); - }); - } - - return text || key; - }; - - // 다국어 API 테스트 함수 (getUITextSync 사용) - const testMultiLangAPI = async () => { - // console.log("🧪 다국어 API 테스트 시작"); - try { - const text = getUITextSync("menu.management.admin"); - // console.log("🧪 다국어 API 테스트 결과:", text); - } catch (error) { - // console.error("❌ 다국어 API 테스트 실패:", error); - } - }; - - // 대문자 키를 소문자 키로 변환하는 함수 - const convertMenuData = (data: any[]): MenuItem[] => { - return data.map((item) => ({ - objid: item.OBJID || item.objid, - parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id, - menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor, - menu_url: item.MENU_URL || item.menu_url, - menu_desc: item.MENU_DESC || item.menu_desc, - seq: item.SEQ || item.seq, - menu_type: item.MENU_TYPE || item.menu_type, - status: item.STATUS || item.status, - lev: item.LEV || item.lev, - lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor, - status_title: item.STATUS_TITLE || item.status_title, - writer: item.WRITER || item.writer, - regdate: item.REGDATE || item.regdate, - company_code: item.COMPANY_CODE || item.company_code, - company_name: item.COMPANY_NAME || item.company_name, - })); - }; - - const handleAddTopLevelMenu = () => { - setFormData({ - menuId: "", - parentId: "0", // 최상위 메뉴는 parentId가 0 - menuType: getMenuTypeValue(), - level: 1, // 최상위 메뉴는 level 1 - parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음 - }); - setFormModalOpen(true); - }; - - const handleAddMenu = (parentId: string, menuType: string, level: number) => { - // 상위 메뉴의 회사 정보 찾기 - const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; - const parentMenu = currentMenus.find((menu) => menu.objid === parentId); - - setFormData({ - menuId: "", - parentId, - menuType, - level: level + 1, - parentCompanyCode: parentMenu?.company_code || "", - }); - setFormModalOpen(true); - }; - - const handleEditMenu = (menuId: string) => { - // console.log("🔧 메뉴 수정 시작 - menuId:", menuId); - - // 현재 메뉴 정보 찾기 - const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; - const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId); - - if (menuToEdit) { - // console.log("수정할 메뉴 정보:", menuToEdit); - - setFormData({ - menuId: menuId, - parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "", - menuType: selectedMenuType, // 현재 선택된 메뉴 타입 - level: 0, // 기본값 - parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "", - }); - - // console.log("설정된 formData:", { - // menuId: menuId, - // parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "", - // menuType: selectedMenuType, - // level: 0, - // parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "", - // }); - } else { - // console.error("수정할 메뉴를 찾을 수 없음:", menuId); - } - - setFormModalOpen(true); - }; - - const handleMenuSelectionChange = (menuId: string, checked: boolean) => { - const newSelected = new Set(selectedMenus); - if (checked) { - newSelected.add(menuId); - } else { - newSelected.delete(menuId); - } - setSelectedMenus(newSelected); - }; - - const handleSelectAllMenus = (checked: boolean) => { - const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; - if (checked) { - // 모든 메뉴 선택 (최상위 메뉴 포함) - setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || ""))); - } else { - setSelectedMenus(new Set()); - } - }; - - const handleDeleteSelectedMenus = async () => { - if (selectedMenus.size === 0) { - toast.error(getUITextSync("message.validation.select.menu.delete")); - return; - } - - if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) { - return; - } - - setDeleting(true); - try { - const menuIds = Array.from(selectedMenus); - // console.log("삭제할 메뉴 IDs:", menuIds); - - toast.info(getUITextSync("message.menu.delete.processing")); - - const response = await menuApi.deleteMenusBatch(menuIds); - // console.log("삭제 API 응답:", response); - // console.log("응답 구조:", { - // success: response.success, - // data: response.data, - // message: response.message, - // }); - - if (response.success && response.data) { - const { deletedCount, failedCount } = response.data; - // console.log("삭제 결과:", { deletedCount, failedCount }); - - // 선택된 메뉴 초기화 - setSelectedMenus(new Set()); - - // 메뉴 목록 즉시 새로고침 (로딩 상태 없이) - // console.log("메뉴 목록 새로고침 시작"); - await loadMenus(false); - // 전역 메뉴 상태도 업데이트 - await refreshMenus(); - // console.log("메뉴 목록 새로고침 완료"); - - // 삭제 결과 메시지 - if (failedCount === 0) { - toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount })); - } else { - toast.success( - getUITextSync("message.menu.delete.batch.partial", { - success: deletedCount, - failed: failedCount, - }), - ); - } - } else { - // console.error("삭제 실패:", response); - toast.error(response.message || "메뉴 삭제에 실패했습니다."); - } - } catch (error) { - // console.error("메뉴 삭제 중 오류:", error); - toast.error(getUITextSync("message.menu.delete.failed")); - } finally { - setDeleting(false); - } - }; - - const confirmDelete = async () => { - try { - const response = await menuApi.deleteMenu(selectedMenuId); - if (response.success) { - toast.success(response.message); - await loadMenus(false); - } else { - toast.error(response.message); - } - } catch (error) { - toast.error("메뉴 삭제에 실패했습니다."); - } finally { - setDeleteDialogOpen(false); - setSelectedMenuId(""); - } - }; - - const handleCopyMenu = (menuId: string, menuName: string) => { - setSelectedMenuId(menuId); - setSelectedMenuName(menuName); - setCopyDialogOpen(true); - }; - - const handleCopyComplete = async () => { - // 복사 완료 후 메뉴 목록 새로고침 - await loadMenus(false); - toast.success("메뉴 복사가 완료되었습니다"); - }; - - const handleToggleStatus = async (menuId: string) => { - try { - const response = await menuApi.toggleMenuStatus(menuId); - if (response.success) { - toast.success(response.message); - await loadMenus(false); // 메뉴 목록 새로고침 - // 전역 메뉴 상태도 업데이트 - await refreshMenus(); - } else { - toast.error(response.message); - } - } catch (error) { - // console.error("메뉴 상태 토글 오류:", error); - toast.error(getUITextSync("message.menu.status.toggle.failed")); - } - }; - - const handleFormSuccess = () => { - loadMenus(false); - // 전역 메뉴 상태도 업데이트 - refreshMenus(); - }; - - const getCurrentMenus = () => { - // 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용) - const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus; - - // 검색어 필터링 - let filteredMenus = currentMenus; - if (searchText.trim()) { - const searchLower = searchText.toLowerCase(); - filteredMenus = currentMenus.filter((menu) => { - const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase(); - const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase(); - return menuName.includes(searchLower) || menuUrl.includes(searchLower); - }); - } - - // 회사 필터링 - if (selectedCompany !== "all") { - filteredMenus = filteredMenus.filter((menu) => { - const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || ""; - return menuCompanyCode === selectedCompany; - }); - } - - return filteredMenus; - }; - - // 메뉴 타입 변경 시 선택된 메뉴 초기화 - const handleMenuTypeChange = (type: MenuType) => { - setSelectedMenuType(type); - setSelectedMenus(new Set()); // 선택된 메뉴 초기화 - setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화 - - // 선택한 메뉴 타입에 해당하는 메뉴만 로드 - if (type === "admin" && localAdminMenus.length === 0) { - loadMenusForType("admin", false); - } else if (type === "user" && localUserMenus.length === 0) { - loadMenusForType("user", false); - } - }; - - const handleToggleExpand = (menuId: string) => { - const newExpandedMenus = new Set(expandedMenus); - if (newExpandedMenus.has(menuId)) { - newExpandedMenus.delete(menuId); - } else { - newExpandedMenus.add(menuId); - } - setExpandedMenus(newExpandedMenus); - }; - - const getMenuTypeString = () => { - return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user"); - }; - - const getMenuTypeValue = () => { - return selectedMenuType === "admin" ? "0" : "1"; - }; - - // uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산 - const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]); - const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]); - const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]); - - // 디버깅을 위한 간단한 상태 표시 - // console.log("🔍 MenuManagement 렌더링 상태:", { - // loading, - // uiTextsLoading, - // uiTextsCount, - // adminMenusCount, - // userMenusCount, - // selectedMenuType, - // userLang, - // }); - - if (loading) { - return ( -
- -
- ); - } - - return ( - -
- {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */} -
-
-

{getUITextSync("menu.type.title")}

- - {/* 메뉴 타입 선택 카드들 */} -
-
handleMenuTypeChange("admin")} - > -
-
-

{getUITextSync("menu.management.admin")}

-

- {getUITextSync("menu.management.admin.description")} -

-
- - {localAdminMenus.length} - -
-
- -
handleMenuTypeChange("user")} - > -
-
-

{getUITextSync("menu.management.user")}

-

- {getUITextSync("menu.management.user.description")} -

-
- - {localUserMenus.length} - -
-
-
-
-
- - {/* 우측 메인 영역 - 메뉴 목록 (80%) */} -
-
- {/* 상단 헤더: 제목 + 검색 + 버튼 */} -
- {/* 왼쪽: 제목 */} -

- {getMenuTypeString()} {getUITextSync("menu.list.title")} -

- - {/* 오른쪽: 검색 + 버튼 */} -
- {/* 회사 선택 */} -
-
- - - {isCompanyDropdownOpen && ( -
-
- setCompanySearchText(e.target.value)} - className="h-8 text-sm" - onClick={(e) => e.stopPropagation()} - /> -
- -
-
{ - setSelectedCompany("all"); - setIsCompanyDropdownOpen(false); - setCompanySearchText(""); - }} - > - {getUITextSync("filter.company.all")} -
-
{ - setSelectedCompany("*"); - setIsCompanyDropdownOpen(false); - setCompanySearchText(""); - }} - > - {getUITextSync("filter.company.common")} -
- - {companies - .filter((company) => company.code && company.code.trim() !== "") - .filter( - (company) => - company.name.toLowerCase().includes(companySearchText.toLowerCase()) || - company.code.toLowerCase().includes(companySearchText.toLowerCase()), - ) - .map((company, index) => ( -
{ - setSelectedCompany(company.code); - setIsCompanyDropdownOpen(false); - setCompanySearchText(""); - }} - > - {company.code === "*" ? getUITextSync("filter.company.common") : company.name} -
- ))} -
-
- )} -
-
- - {/* 검색 입력 */} -
- setSearchText(e.target.value)} - className="h-10 text-sm" - /> -
- - {/* 초기화 버튼 */} - - - {/* 최상위 메뉴 추가 */} - - - {/* 선택 삭제 */} - {selectedMenus.size > 0 && ( - - )} -
-
- - {/* 테이블 영역 */} -
- -
-
-
-
- - setFormModalOpen(false)} - onSuccess={handleFormSuccess} - menuId={formData.menuId} - parentId={formData.parentId} - menuType={formData.menuType} - level={formData.level} - parentCompanyCode={formData.parentCompanyCode} - uiTexts={uiTexts} - /> - - - - - 메뉴 삭제 - - 해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. - - - - 취소 - 삭제 - - - - - -
- ); -}; diff --git a/frontend/components/admin/MonitoringDashboard.tsx b/frontend/components/admin/MonitoringDashboard.tsx deleted file mode 100644 index 500dd4fb..00000000 --- a/frontend/components/admin/MonitoringDashboard.tsx +++ /dev/null @@ -1,288 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Progress } from "@/components/ui/progress"; -import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react"; -import { toast } from "sonner"; -import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch"; - -export default function MonitoringDashboard() { - const [monitoring, setMonitoring] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [autoRefresh, setAutoRefresh] = useState(false); - - useEffect(() => { - loadMonitoringData(); - - let interval: NodeJS.Timeout; - if (autoRefresh) { - interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침 - } - - return () => { - if (interval) clearInterval(interval); - }; - }, [autoRefresh]); - - const loadMonitoringData = async () => { - setIsLoading(true); - try { - const data = await BatchAPI.getBatchMonitoring(); - setMonitoring(data); - } catch (error) { - console.error("모니터링 데이터 조회 오류:", error); - toast.error("모니터링 데이터를 불러오는데 실패했습니다."); - } finally { - setIsLoading(false); - } - }; - - const handleRefresh = () => { - loadMonitoringData(); - }; - - const toggleAutoRefresh = () => { - setAutoRefresh(!autoRefresh); - }; - - const getStatusIcon = (status: string) => { - switch (status) { - case 'completed': - return ; - case 'failed': - return ; - case 'running': - return ; - case 'pending': - return ; - default: - return ; - } - }; - - const getStatusBadge = (status: string) => { - const variants = { - completed: "bg-green-100 text-green-800", - failed: "bg-destructive/20 text-red-800", - running: "bg-primary/20 text-blue-800", - pending: "bg-yellow-100 text-yellow-800", - cancelled: "bg-gray-100 text-gray-800", - }; - - const labels = { - completed: "완료", - failed: "실패", - running: "실행 중", - pending: "대기 중", - cancelled: "취소됨", - }; - - return ( - - {labels[status as keyof typeof labels] || status} - - ); - }; - - const formatDuration = (ms: number) => { - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - return `${(ms / 60000).toFixed(1)}m`; - }; - - const getSuccessRate = () => { - if (!monitoring) return 0; - const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today; - if (total === 0) return 100; - return Math.round((monitoring.successful_jobs_today / total) * 100); - }; - - if (!monitoring) { - return ( -
-
- -

모니터링 데이터를 불러오는 중...

-
-
- ); - } - - return ( -
- {/* 헤더 */} -
-

배치 모니터링

-
- - -
-
- - {/* 통계 카드 */} -
- - - 총 작업 수 -
📋
-
- -
{monitoring.total_jobs}
-

- 활성: {monitoring.active_jobs}개 -

-
-
- - - - 실행 중 -
🔄
-
- -
{monitoring.running_jobs}
-

- 현재 실행 중인 작업 -

-
-
- - - - 오늘 성공 -
-
- -
{monitoring.successful_jobs_today}
-

- 성공률: {getSuccessRate()}% -

-
-
- - - - 오늘 실패 -
-
- -
{monitoring.failed_jobs_today}
-

- 주의가 필요한 작업 -

-
-
-
- - {/* 성공률 진행바 */} - - - 오늘 실행 성공률 - - -
-
- 성공: {monitoring.successful_jobs_today}건 - 실패: {monitoring.failed_jobs_today}건 -
- -
- {getSuccessRate()}% 성공률 -
-
-
-
- - {/* 최근 실행 이력 */} - - - 최근 실행 이력 - - - {monitoring.recent_executions.length === 0 ? ( -
- 최근 실행 이력이 없습니다. -
- ) : ( - - - - 상태 - 작업 ID - 시작 시간 - 완료 시간 - 실행 시간 - 오류 메시지 - - - - {monitoring.recent_executions.map((execution) => ( - - -
- {getStatusIcon(execution.execution_status)} - {getStatusBadge(execution.execution_status)} -
-
- #{execution.job_id} - - {execution.started_at - ? new Date(execution.started_at).toLocaleString() - : "-"} - - - {execution.completed_at - ? new Date(execution.completed_at).toLocaleString() - : "-"} - - - {execution.execution_time_ms - ? formatDuration(execution.execution_time_ms) - : "-"} - - - {execution.error_message ? ( - - {execution.error_message} - - ) : ( - "-" - )} - -
- ))} -
-
- )} -
-
-
- ); -} diff --git a/frontend/components/admin/MultiLang.tsx b/frontend/components/admin/MultiLang.tsx deleted file mode 100644 index abdadcdb..00000000 --- a/frontend/components/admin/MultiLang.tsx +++ /dev/null @@ -1,859 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; - -import { DataTable } from "@/components/common/DataTable"; -import { LoadingSpinner } from "@/components/common/LoadingSpinner"; -import { useAuth } from "@/hooks/useAuth"; -import LangKeyModal from "./LangKeyModal"; -import LanguageModal from "./LanguageModal"; -import { apiClient } from "@/lib/api/client"; - -interface Language { - langCode: string; - langName: string; - langNative: string; - isActive: string; -} - -interface LangKey { - keyId: number; - companyCode: string; - menuName: string; - langKey: string; - description: string; - isActive: string; -} - -interface LangText { - textId: number; - keyId: number; - langCode: string; - langText: string; - isActive: string; -} - -export default function MultiLangPage() { - const { user } = useAuth(); - const [loading, setLoading] = useState(true); - const [languages, setLanguages] = useState([]); - const [langKeys, setLangKeys] = useState([]); - const [selectedKey, setSelectedKey] = useState(null); - const [langTexts, setLangTexts] = useState([]); - const [editingTexts, setEditingTexts] = useState([]); - const [selectedCompany, setSelectedCompany] = useState("all"); - const [searchText, setSearchText] = useState(""); - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingKey, setEditingKey] = useState(null); - const [selectedKeys, setSelectedKeys] = useState>(new Set()); - - // 언어 관리 관련 상태 - const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false); - const [editingLanguage, setEditingLanguage] = useState(null); - const [selectedLanguages, setSelectedLanguages] = useState>(new Set()); - const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys"); - - const [companies, setCompanies] = useState>([]); - - // 회사 목록 조회 - const fetchCompanies = async () => { - try { - // console.log("회사 목록 조회 시작"); - const response = await apiClient.get("/admin/companies"); - // console.log("회사 목록 응답 데이터:", response.data); - - const data = response.data; - if (data.success) { - const companyList = data.data.map((company: any) => ({ - code: company.company_code, - name: company.company_name, - })); - // console.log("변환된 회사 목록:", companyList); - setCompanies(companyList); - } else { - // console.error("회사 목록 조회 실패:", data.message); - } - } catch (error) { - // console.error("회사 목록 조회 실패:", error); - } - }; - - // 언어 목록 조회 - const fetchLanguages = async () => { - try { - const response = await apiClient.get("/multilang/languages"); - const data = response.data; - if (data.success) { - setLanguages(data.data); - } - } catch (error) { - // console.error("언어 목록 조회 실패:", error); - } - }; - - // 다국어 키 목록 조회 - const fetchLangKeys = async () => { - try { - const response = await apiClient.get("/multilang/keys"); - const data = response.data; - if (data.success) { - // console.log("✅ 전체 키 목록 로드:", data.data.length, "개"); - setLangKeys(data.data); - } else { - // console.error("❌ 키 목록 로드 실패:", data.message); - } - } catch (error) { - // console.error("다국어 키 목록 조회 실패:", error); - } - }; - - // 필터링된 데이터 계산 - 메뉴관리와 동일한 방식 - const getFilteredLangKeys = () => { - let filteredKeys = langKeys; - - // 회사 필터링 - if (selectedCompany && selectedCompany !== "all") { - filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany); - } - - // 텍스트 검색 필터링 - if (searchText.trim()) { - const searchLower = searchText.toLowerCase(); - filteredKeys = filteredKeys.filter((key) => { - const langKey = (key.langKey || "").toLowerCase(); - const description = (key.description || "").toLowerCase(); - const menuName = (key.menuName || "").toLowerCase(); - const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || ""; - - return ( - langKey.includes(searchLower) || - description.includes(searchLower) || - menuName.includes(searchLower) || - companyName.includes(searchLower) - ); - }); - } - - return filteredKeys; - }; - - // 선택된 키의 다국어 텍스트 조회 - const fetchLangTexts = async (keyId: number) => { - try { - // console.log("다국어 텍스트 조회 시작: keyId =", keyId); - const response = await apiClient.get(`/multilang/keys/${keyId}/texts`); - const data = response.data; - // console.log("다국어 텍스트 조회 응답:", data); - if (data.success) { - setLangTexts(data.data); - // 편집용 텍스트 초기화 - const editingData = data.data.map((text: LangText) => ({ ...text })); - setEditingTexts(editingData); - // console.log("편집용 텍스트 설정:", editingData); - } - } catch (error) { - // console.error("다국어 텍스트 조회 실패:", error); - } - }; - - // 언어 키 선택 처리 - const handleKeySelect = (key: LangKey) => { - // console.log("언어 키 선택:", key); - setSelectedKey(key); - fetchLangTexts(key.keyId); - }; - - // 디버깅용 useEffect - useEffect(() => { - if (selectedKey) { - // console.log("선택된 키 변경:", selectedKey); - // console.log("언어 목록:", languages); - // console.log("편집 텍스트:", editingTexts); - } - }, [selectedKey, languages, editingTexts]); - - // 텍스트 변경 처리 - const handleTextChange = (langCode: string, value: string) => { - const newEditingTexts = [...editingTexts]; - const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode); - - if (existingIndex >= 0) { - newEditingTexts[existingIndex].langText = value; - } else { - newEditingTexts.push({ - textId: 0, - keyId: selectedKey!.keyId, - langCode: langCode, - langText: value, - isActive: "Y", - }); - } - - setEditingTexts(newEditingTexts); - }; - - // 텍스트 저장 - const handleSave = async () => { - if (!selectedKey) return; - - try { - // 백엔드가 기대하는 형식으로 데이터 변환 - const requestData = { - texts: editingTexts.map((text) => ({ - langCode: text.langCode, - langText: text.langText, - isActive: text.isActive || "Y", - createdBy: user?.userId || "system", - updatedBy: user?.userId || "system", - })), - }; - - const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData); - const data = response.data; - if (data.success) { - alert("저장되었습니다."); - // 저장 후 다시 조회 - fetchLangTexts(selectedKey.keyId); - } - } catch (error) { - // console.error("텍스트 저장 실패:", error); - alert("저장에 실패했습니다."); - } - }; - - // 언어 키 추가/수정 모달 열기 - const handleAddKey = () => { - setEditingKey(null); // 새 키 추가는 null로 설정 - setIsModalOpen(true); - }; - - // 언어 추가/수정 모달 열기 - const handleAddLanguage = () => { - setEditingLanguage(null); - setIsLanguageModalOpen(true); - }; - - // 언어 수정 - const handleEditLanguage = (language: Language) => { - setEditingLanguage(language); - setIsLanguageModalOpen(true); - }; - - // 언어 저장 (추가/수정) - const handleSaveLanguage = async (languageData: any) => { - try { - const requestData = { - ...languageData, - createdBy: user?.userId || "admin", - updatedBy: user?.userId || "admin", - }; - - let response; - if (editingLanguage) { - response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData); - } else { - response = await apiClient.post("/multilang/languages", requestData); - } - - const result = response.data; - - if (result.success) { - alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다."); - setIsLanguageModalOpen(false); - fetchLanguages(); // 언어 목록 새로고침 - } else { - alert(`오류: ${result.message}`); - } - } catch (error) { - // console.error("언어 저장 중 오류:", error); - alert("언어 저장 중 오류가 발생했습니다."); - } - }; - - // 언어 삭제 - const handleDeleteLanguages = async () => { - if (selectedLanguages.size === 0) { - alert("삭제할 언어를 선택해주세요."); - return; - } - - if ( - !confirm( - `선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`, - ) - ) { - return; - } - - try { - const deletePromises = Array.from(selectedLanguages).map((langCode) => - apiClient.delete(`/multilang/languages/${langCode}`), - ); - - const responses = await Promise.all(deletePromises); - const failedDeletes = responses.filter((response) => !response.data.success); - - if (failedDeletes.length === 0) { - alert("선택된 언어가 삭제되었습니다."); - setSelectedLanguages(new Set()); - fetchLanguages(); // 언어 목록 새로고침 - } else { - alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`); - } - } catch (error) { - // console.error("언어 삭제 중 오류:", error); - alert("언어 삭제 중 오류가 발생했습니다."); - } - }; - - // 언어 선택 체크박스 처리 - const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => { - const newSelected = new Set(selectedLanguages); - if (checked) { - newSelected.add(langCode); - } else { - newSelected.delete(langCode); - } - setSelectedLanguages(newSelected); - }; - - // 언어 전체 선택/해제 - const handleSelectAllLanguages = (checked: boolean) => { - if (checked) { - setSelectedLanguages(new Set(languages.map((lang) => lang.langCode))); - } else { - setSelectedLanguages(new Set()); - } - }; - - // 언어 키 수정 모달 열기 - const handleEditKey = (key: LangKey) => { - setEditingKey(key); - setIsModalOpen(true); - }; - - // 언어 키 저장 (추가/수정) - const handleSaveKey = async (keyData: any) => { - try { - const requestData = { - ...keyData, - createdBy: user?.userId || "admin", - updatedBy: user?.userId || "admin", - }; - - let response; - if (editingKey) { - response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData); - } else { - response = await apiClient.post("/multilang/keys", requestData); - } - - const data = response.data; - - if (data.success) { - alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다."); - fetchLangKeys(); // 목록 새로고침 - setIsModalOpen(false); - } else { - // 중복 체크 오류 메시지 처리 - if (data.message && data.message.includes("이미 존재하는 언어키")) { - alert(data.message); - } else { - alert(data.message || "언어 키 저장에 실패했습니다."); - } - } - } catch (error) { - // console.error("언어 키 저장 실패:", error); - alert("언어 키 저장에 실패했습니다."); - } - }; - - // 체크박스 선택/해제 - const handleCheckboxChange = (keyId: number, checked: boolean) => { - const newSelectedKeys = new Set(selectedKeys); - if (checked) { - newSelectedKeys.add(keyId); - } else { - newSelectedKeys.delete(keyId); - } - setSelectedKeys(newSelectedKeys); - }; - - // 키 상태 토글 - const handleToggleStatus = async (keyId: number) => { - try { - const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`); - const data = response.data; - if (data.success) { - alert(`키가 ${data.data}되었습니다.`); - fetchLangKeys(); - } else { - alert("상태 변경 중 오류가 발생했습니다."); - } - } catch (error) { - // console.error("키 상태 토글 실패:", error); - alert("키 상태 변경 중 오류가 발생했습니다."); - } - }; - - // 언어 상태 토글 - const handleToggleLanguageStatus = async (langCode: string) => { - try { - const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`); - const data = response.data; - if (data.success) { - alert(`언어가 ${data.data}되었습니다.`); - fetchLanguages(); - } else { - alert("언어 상태 변경 중 오류가 발생했습니다."); - } - } catch (error) { - // console.error("언어 상태 토글 실패:", error); - alert("언어 상태 변경 중 오류가 발생했습니다."); - } - }; - - // 전체 선택/해제 - const handleSelectAll = (checked: boolean) => { - if (checked) { - const allKeyIds = getFilteredLangKeys().map((key) => key.keyId); - setSelectedKeys(new Set(allKeyIds)); - } else { - setSelectedKeys(new Set()); - } - }; - - // 선택된 키들 일괄 삭제 - const handleDeleteSelectedKeys = async () => { - if (selectedKeys.size === 0) { - alert("삭제할 키를 선택해주세요."); - return; - } - - if ( - !confirm( - `선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`, - ) - ) { - return; - } - - try { - const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`)); - - const responses = await Promise.all(deletePromises); - const allSuccess = responses.every((response) => response.data.success); - - if (allSuccess) { - alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`); - setSelectedKeys(new Set()); - fetchLangKeys(); // 목록 새로고침 - - // 선택된 키가 삭제된 경우 편집 영역 닫기 - if (selectedKey && selectedKeys.has(selectedKey.keyId)) { - handleCancel(); - } - } else { - alert("일부 키 삭제에 실패했습니다."); - } - } catch (error) { - // console.error("선택된 키 삭제 실패:", error); - alert("선택된 키 삭제에 실패했습니다."); - } - }; - - // 개별 키 삭제 (기존 함수 유지) - const handleDeleteKey = async (keyId: number) => { - if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) { - return; - } - - try { - const response = await apiClient.delete(`/multilang/keys/${keyId}`); - const data = response.data; - if (data.success) { - alert("언어 키가 영구적으로 삭제되었습니다."); - fetchLangKeys(); // 목록 새로고침 - if (selectedKey && selectedKey.keyId === keyId) { - handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기 - } - } - } catch (error) { - // console.error("언어 키 삭제 실패:", error); - alert("언어 키 삭제에 실패했습니다."); - } - }; - - // 취소 처리 - const handleCancel = () => { - setSelectedKey(null); - setLangTexts([]); - setEditingTexts([]); - }; - - useEffect(() => { - const initializeData = async () => { - setLoading(true); - await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]); - setLoading(false); - }; - initializeData(); - }, []); - - // 검색 관련 useEffect 제거 - 실시간 필터링만 사용 - - const columns = [ - { - id: "select", - header: () => { - const filteredKeys = getFilteredLangKeys(); - return ( - 0} - onChange={(e) => handleSelectAll(e.target.checked)} - className="h-4 w-4" - /> - ); - }, - cell: ({ row }: any) => ( - handleCheckboxChange(row.original.keyId, e.target.checked)} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4" - disabled={row.original.isActive === "N"} - /> - ), - }, - { - accessorKey: "companyCode", - header: "회사", - cell: ({ row }: any) => { - const companyName = - row.original.companyCode === "*" - ? "공통" - : companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode; - - return {companyName}; - }, - }, - { - accessorKey: "menuName", - header: "메뉴명", - cell: ({ row }: any) => ( - {row.original.menuName} - ), - }, - - { - accessorKey: "langKey", - header: "언어 키", - cell: ({ row }: any) => ( -
handleEditKey(row.original)} - > - {row.original.langKey} -
- ), - }, - - { - accessorKey: "description", - header: "설명", - cell: ({ row }: any) => ( - {row.original.description} - ), - }, - { - accessorKey: "isActive", - header: "상태", - cell: ({ row }: any) => ( - - ), - }, - ]; - - // 언어 테이블 컬럼 정의 - const languageColumns = [ - { - id: "select", - header: () => ( - 0} - onChange={(e) => handleSelectAllLanguages(e.target.checked)} - className="h-4 w-4" - /> - ), - cell: ({ row }: any) => ( - handleLanguageCheckboxChange(row.original.langCode, e.target.checked)} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4" - disabled={row.original.isActive === "N"} - /> - ), - }, - { - accessorKey: "langCode", - header: "언어 코드", - cell: ({ row }: any) => ( -
handleEditLanguage(row.original)} - > - {row.original.langCode} -
- ), - }, - { - accessorKey: "langName", - header: "언어명 (영문)", - cell: ({ row }: any) => ( - {row.original.langName} - ), - }, - { - accessorKey: "langNative", - header: "언어명 (원어)", - cell: ({ row }: any) => ( - {row.original.langNative} - ), - }, - { - accessorKey: "isActive", - header: "상태", - cell: ({ row }: any) => ( - - ), - }, - ]; - - if (loading) { - return ; - } - - return ( -
- {/* 탭 네비게이션 */} -
- - -
- - {/* 메인 콘텐츠 영역 */} -
- {/* 언어 관리 탭 */} - {activeTab === "languages" && ( - - - 언어 관리 - - -
-
총 {languages.length}개의 언어가 등록되어 있습니다.
-
- {selectedLanguages.size > 0 && ( - - )} - -
-
- -
-
- )} - - {/* 다국어 키 관리 탭의 메인 영역 */} - {activeTab === "keys" && ( -
- {/* 좌측: 언어 키 목록 (7/10) */} - - -
- 언어 키 목록 -
- - -
-
-
- - {/* 검색 필터 영역 */} -
-
- - -
- -
- - setSearchText(e.target.value)} - /> -
- -
-
검색 결과: {getFilteredLangKeys().length}건
-
-
- - {/* 테이블 영역 */} -
-
전체: {getFilteredLangKeys().length}건
- -
-
-
- - {/* 우측: 선택된 키의 다국어 관리 (3/10) */} - - - - {selectedKey ? ( - <> - 선택된 키:{" "} - - {selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey} - - - ) : ( - "다국어 편집" - )} - - - - {selectedKey ? ( -
- {/* 스크롤 가능한 텍스트 영역 */} -
- {languages - .filter((lang) => lang.isActive === "Y") - .map((lang) => { - const text = editingTexts.find((t) => t.langCode === lang.langCode); - return ( -
- - {lang.langName} - - handleTextChange(lang.langCode, e.target.value)} - className="flex-1" - /> -
- ); - })} -
- {/* 저장 버튼 - 고정 위치 */} -
- - -
-
- ) : ( -
-
-
언어 키를 선택하세요
-
좌측 목록에서 편집할 언어 키를 클릭하세요
-
-
- )} -
-
-
- )} -
- - {/* 언어 키 추가/수정 모달 */} - setIsModalOpen(false)} - onSave={handleSaveKey} - keyData={editingKey} - companies={companies} - /> - - {/* 언어 추가/수정 모달 */} - setIsLanguageModalOpen(false)} - onSave={handleSaveLanguage} - languageData={editingLanguage} - /> -
- ); -} diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/components/admin/RoleDetailManagement.tsx deleted file mode 100644 index 92d03143..00000000 --- a/frontend/components/admin/RoleDetailManagement.tsx +++ /dev/null @@ -1,345 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react"; -import { roleAPI, RoleGroup } from "@/lib/api/role"; -import { useAuth } from "@/hooks/useAuth"; -import { useRouter } from "next/navigation"; -import { AlertCircle } from "lucide-react"; -import { DualListBox } from "@/components/common/DualListBox"; -import { MenuPermissionsTable } from "./MenuPermissionsTable"; -import { useMenu } from "@/contexts/MenuContext"; - -interface RoleDetailManagementProps { - roleId: string; -} - -/** - * 권한 그룹 상세 관리 컴포넌트 - * - * 기능: - * - 권한 그룹 정보 표시 - * - 멤버 관리 (Dual List Box) - * - 메뉴 권한 설정 (CRUD 체크박스) - */ -export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { - const { user: currentUser } = useAuth(); - const router = useRouter(); - const { refreshMenus } = useMenu(); - - const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; - - // 상태 관리 - const [roleGroup, setRoleGroup] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // 탭 상태 - const [activeTab, setActiveTab] = useState<"members" | "permissions">("members"); - - // 멤버 관리 상태 - const [availableUsers, setAvailableUsers] = useState>([]); - const [selectedUsers, setSelectedUsers] = useState>([]); - const [isSavingMembers, setIsSavingMembers] = useState(false); - - // 메뉴 권한 상태 - const [menuPermissions, setMenuPermissions] = useState([]); - const [isSavingPermissions, setIsSavingPermissions] = useState(false); - - // 데이터 로드 - const loadRoleGroup = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const response = await roleAPI.getById(parseInt(roleId, 10)); - - if (response.success && response.data) { - setRoleGroup(response.data); - } else { - setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다."); - } - } catch (err) { - console.error("권한 그룹 정보 로드 오류:", err); - setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }, [roleId]); - - // 멤버 목록 로드 - const loadMembers = useCallback(async () => { - if (!roleGroup) return; - - try { - // 1. 권한 그룹 멤버 조회 - const membersResponse = await roleAPI.getMembers(roleGroup.objid); - if (membersResponse.success && membersResponse.data) { - setSelectedUsers( - membersResponse.data.map((member: any) => ({ - id: member.userId, - label: member.userName || member.userId, - description: member.deptName, - })), - ); - } - - // 2. 전체 사용자 목록 조회 (같은 회사) - const userAPI = await import("@/lib/api/user"); - - console.log("🔍 사용자 목록 조회 요청:", { - companyCode: roleGroup.companyCode, - size: 1000, - }); - - const usersResponse = await userAPI.userAPI.getList({ - companyCode: roleGroup.companyCode, - size: 1000, // 대량 조회 - }); - - console.log("✅ 사용자 목록 응답:", { - success: usersResponse.success, - count: usersResponse.data?.length, - total: usersResponse.total, - }); - - if (usersResponse.success && usersResponse.data) { - setAvailableUsers( - usersResponse.data.map((user: any) => ({ - id: user.userId, - label: user.userName || user.userId, - description: user.deptName, - })), - ); - console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length); - } - } catch (err) { - console.error("멤버 목록 로드 오류:", err); - } - }, [roleGroup]); - - // 메뉴 권한 로드 - const loadMenuPermissions = useCallback(async () => { - if (!roleGroup) return; - - console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", { - roleGroupId: roleGroup.objid, - roleGroupName: roleGroup.authName, - companyCode: roleGroup.companyCode, - }); - - try { - const response = await roleAPI.getMenuPermissions(roleGroup.objid); - - console.log("✅ [loadMenuPermissions] API 응답", { - success: response.success, - dataCount: response.data?.length, - data: response.data, - }); - - if (response.success && response.data) { - setMenuPermissions(response.data); - console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", { - count: response.data.length, - }); - } else { - console.warn("⚠️ [loadMenuPermissions] 응답 실패", { - message: response.message, - }); - } - } catch (err) { - console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err); - } - }, [roleGroup]); - - useEffect(() => { - loadRoleGroup(); - }, [loadRoleGroup]); - - useEffect(() => { - if (roleGroup && activeTab === "members") { - loadMembers(); - } else if (roleGroup && activeTab === "permissions") { - loadMenuPermissions(); - } - }, [roleGroup, activeTab, loadMembers, loadMenuPermissions]); - - // 멤버 저장 핸들러 - const handleSaveMembers = useCallback(async () => { - if (!roleGroup) return; - - setIsSavingMembers(true); - try { - // 현재 선택된 사용자 ID 목록 - const selectedUserIds = selectedUsers.map((user) => user.id); - - // 멤버 업데이트 API 호출 - const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds); - - if (response.success) { - alert("멤버가 성공적으로 저장되었습니다."); - loadMembers(); // 새로고침 - - // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음) - await refreshMenus(); - } else { - alert(response.message || "멤버 저장에 실패했습니다."); - } - } catch (err) { - console.error("멤버 저장 오류:", err); - alert("멤버 저장 중 오류가 발생했습니다."); - } finally { - setIsSavingMembers(false); - } - }, [roleGroup, selectedUsers, loadMembers, refreshMenus]); - - // 메뉴 권한 저장 핸들러 - const handleSavePermissions = useCallback(async () => { - if (!roleGroup) return; - - setIsSavingPermissions(true); - try { - const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions); - - if (response.success) { - alert("메뉴 권한이 성공적으로 저장되었습니다."); - loadMenuPermissions(); // 새로고침 - - // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영) - await refreshMenus(); - } else { - alert(response.message || "메뉴 권한 저장에 실패했습니다."); - } - } catch (err) { - console.error("메뉴 권한 저장 오류:", err); - alert("메뉴 권한 저장 중 오류가 발생했습니다."); - } finally { - setIsSavingPermissions(false); - } - }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]); - - if (isLoading) { - return ( -
-
-
-

권한 그룹 정보를 불러오는 중...

-
-
- ); - } - - if (error || !roleGroup) { - return ( -
- -

오류 발생

-

{error || "권한 그룹을 찾을 수 없습니다."}

- -
- ); - } - - return ( - <> - {/* 페이지 헤더 */} -
-
- -
-

{roleGroup.authName}

-

- {roleGroup.authCode} • {roleGroup.companyCode} -

-
- - {roleGroup.status === "active" ? "활성" : "비활성"} - -
-
- - {/* 탭 네비게이션 */} -
- - -
- - {/* 탭 컨텐츠 */} -
- {activeTab === "members" && ( - <> -
-
-

멤버 관리

-

이 권한 그룹에 속한 사용자를 관리합니다

-
- -
- - - - )} - - {activeTab === "permissions" && ( - <> -
-
-

메뉴 권한 설정

-

이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다

-
- -
- - - - )} -
- - ); -} diff --git a/frontend/components/admin/RoleManagement.tsx b/frontend/components/admin/RoleManagement.tsx deleted file mode 100644 index 3834b2a4..00000000 --- a/frontend/components/admin/RoleManagement.tsx +++ /dev/null @@ -1,335 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react"; -import { roleAPI, RoleGroup } from "@/lib/api/role"; -import { useAuth } from "@/hooks/useAuth"; -import { AlertCircle } from "lucide-react"; -import { RoleFormModal } from "./RoleFormModal"; -import { RoleDeleteModal } from "./RoleDeleteModal"; -import { useRouter } from "next/navigation"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { companyAPI } from "@/lib/api/company"; - -/** - * 권한 그룹 관리 메인 컴포넌트 - * - * 기능: - * - 권한 그룹 목록 조회 (회사별) - * - 권한 그룹 생성/수정/삭제 - * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정) - */ -export function RoleManagement() { - const { user: currentUser } = useAuth(); - const router = useRouter(); - - // 회사 관리자 또는 최고 관리자 여부 - const isAdmin = - (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") || - currentUser?.userType === "COMPANY_ADMIN"; - const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; - - // 상태 관리 - const [roleGroups, setRoleGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // 회사 필터 (최고 관리자 전용) - const [companies, setCompanies] = useState>([]); - const [selectedCompany, setSelectedCompany] = useState("all"); - - // 모달 상태 - const [formModal, setFormModal] = useState({ - isOpen: false, - editingRole: null as RoleGroup | null, - }); - - const [deleteModal, setDeleteModal] = useState({ - isOpen: false, - role: null as RoleGroup | null, - }); - - // 회사 목록 로드 (최고 관리자만) - const loadCompanies = useCallback(async () => { - if (!isSuperAdmin) return; - - try { - const companies = await companyAPI.getList(); - setCompanies(companies); - } catch (error) { - console.error("회사 목록 로드 오류:", error); - } - }, [isSuperAdmin]); - - // 데이터 로드 - const loadRoleGroups = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - // 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회) - // 회사 관리자: 자기 회사만 조회 - const companyFilter = - isSuperAdmin && selectedCompany !== "all" - ? selectedCompany - : isSuperAdmin - ? undefined - : currentUser?.companyCode; - - console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter }); - - const response = await roleAPI.getList({ - companyCode: companyFilter, - }); - - if (response.success && response.data) { - setRoleGroups(response.data); - console.log("권한 그룹 조회 성공:", response.data.length, "개"); - } else { - setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다."); - } - } catch (err) { - console.error("권한 그룹 목록 로드 오류:", err); - setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }, [isSuperAdmin, selectedCompany, currentUser?.companyCode]); - - useEffect(() => { - if (isAdmin) { - if (isSuperAdmin) { - loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드 - } - loadRoleGroups(); - } else { - setIsLoading(false); - } - }, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]); - - // 권한 그룹 생성 핸들러 - const handleCreateRole = useCallback(() => { - setFormModal({ isOpen: true, editingRole: null }); - }, []); - - // 권한 그룹 수정 핸들러 - const handleEditRole = useCallback((role: RoleGroup) => { - setFormModal({ isOpen: true, editingRole: role }); - }, []); - - // 권한 그룹 삭제 핸들러 - const handleDeleteRole = useCallback((role: RoleGroup) => { - setDeleteModal({ isOpen: true, role }); - }, []); - - // 폼 모달 닫기 - const handleFormModalClose = useCallback(() => { - setFormModal({ isOpen: false, editingRole: null }); - }, []); - - // 삭제 모달 닫기 - const handleDeleteModalClose = useCallback(() => { - setDeleteModal({ isOpen: false, role: null }); - }, []); - - // 모달 성공 후 새로고침 - const handleModalSuccess = useCallback(() => { - loadRoleGroups(); - }, [loadRoleGroups]); - - // 상세 페이지로 이동 - const handleViewDetail = useCallback( - (role: RoleGroup) => { - router.push(`/admin/userMng/rolesList/${role.objid}`); - }, - [router], - ); - - // 관리자가 아니면 접근 제한 - if (!isAdmin) { - return ( -
- -

접근 권한 없음

-

- 권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다. -

- -
- ); - } - - return ( - <> - {/* 에러 메시지 */} - {error && ( -
-
-

오류가 발생했습니다

- -
-

{error}

-
- )} - - {/* 액션 버튼 영역 */} -
-
-

권한 그룹 목록 ({roleGroups.length})

- - {/* 최고 관리자 전용: 회사 필터 */} - {isSuperAdmin && ( -
- - - {selectedCompany !== "all" && ( - - )} -
- )} -
- - -
- - {/* 권한 그룹 목록 */} - {isLoading ? ( -
-
-
-

권한 그룹 목록을 불러오는 중...

-
-
- ) : roleGroups.length === 0 ? ( -
-
-

등록된 권한 그룹이 없습니다.

-

권한 그룹을 생성하여 멤버를 관리해보세요.

-
-
- ) : ( -
- {roleGroups.map((role) => ( -
- {/* 헤더 (클릭 시 상세 페이지) */} -
handleViewDetail(role)} - > -
-
-

{role.authName}

-

{role.authCode}

-
- - {role.status === "active" ? "활성" : "비활성"} - -
- - {/* 정보 */} -
- {/* 최고 관리자는 회사명 표시 */} - {isSuperAdmin && ( -
- 회사 - - {companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode} - -
- )} -
- - - 멤버 수 - - {role.memberCount || 0}명 -
-
- - - 메뉴 권한 - - {role.menuCount || 0}개 -
-
-
- - {/* 액션 버튼 */} -
- - -
-
- ))} -
- )} - - {/* 모달들 */} - - - - - ); -} diff --git a/frontend/components/admin/UserAuthManagement.tsx b/frontend/components/admin/UserAuthManagement.tsx deleted file mode 100644 index 27163ba5..00000000 --- a/frontend/components/admin/UserAuthManagement.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useEffect } from "react"; -import { UserAuthTable } from "./UserAuthTable"; -import { UserAuthEditModal } from "./UserAuthEditModal"; -import { userAPI } from "@/lib/api/user"; -import { useAuth } from "@/hooks/useAuth"; -import { AlertCircle } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -/** - * 사용자 권한 관리 메인 컴포넌트 - * - * 기능: - * - 사용자 목록 조회 (권한 정보 포함) - * - 권한 변경 모달 - * - 최고 관리자 권한 체크 - */ -export function UserAuthManagement() { - const { user: currentUser } = useAuth(); - - // 최고 관리자 여부 - const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; - - // 상태 관리 - const [users, setUsers] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [paginationInfo, setPaginationInfo] = useState({ - currentPage: 1, - pageSize: 20, - totalItems: 0, - totalPages: 0, - }); - - // 권한 변경 모달 - const [authEditModal, setAuthEditModal] = useState({ - isOpen: false, - user: null as any | null, - }); - - // 데이터 로드 - const loadUsers = useCallback( - async (page: number = 1) => { - setIsLoading(true); - setError(null); - - try { - const response = await userAPI.getList({ - page, - size: paginationInfo.pageSize, - }); - - if (response.success && response.data) { - setUsers(response.data); - setPaginationInfo({ - currentPage: response.currentPage || page, - pageSize: response.pageSize || paginationInfo.pageSize, - totalItems: response.total || 0, - totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)), - }); - } else { - setError(response.message || "사용자 목록을 불러오는데 실패했습니다."); - } - } catch (err) { - console.error("사용자 목록 로드 오류:", err); - setError("사용자 목록을 불러오는 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }, - [paginationInfo.pageSize], - ); - - useEffect(() => { - loadUsers(1); - }, []); - - // 권한 변경 핸들러 - const handleEditAuth = (user: any) => { - setAuthEditModal({ - isOpen: true, - user, - }); - }; - - // 권한 변경 모달 닫기 - const handleAuthEditClose = () => { - setAuthEditModal({ - isOpen: false, - user: null, - }); - }; - - // 권한 변경 성공 - const handleAuthEditSuccess = () => { - loadUsers(paginationInfo.currentPage); - handleAuthEditClose(); - }; - - // 페이지 변경 - const handlePageChange = (page: number) => { - loadUsers(page); - }; - - // 최고 관리자가 아닌 경우 - if (!isSuperAdmin) { - return ( -
- -

접근 권한 없음

-

권한 관리는 최고 관리자만 접근할 수 있습니다.

- -
- ); - } - - return ( -
- {/* 에러 메시지 */} - {error && ( -
-
-

오류가 발생했습니다

- -
-

{error}

-
- )} - - {/* 사용자 권한 테이블 */} - - - {/* 권한 변경 모달 */} - -
- ); -} diff --git a/frontend/components/admin/UserManagement.tsx b/frontend/components/admin/UserManagement.tsx deleted file mode 100644 index 987b986e..00000000 --- a/frontend/components/admin/UserManagement.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useUserManagement } from "@/hooks/useUserManagement"; -import { UserToolbar } from "./UserToolbar"; -import { UserTable } from "./UserTable"; -import { Pagination } from "@/components/common/Pagination"; -import { UserPasswordResetModal } from "./UserPasswordResetModal"; -import { UserFormModal } from "./UserFormModal"; - -/** - * 사용자 관리 메인 컴포넌트 - * - 원본 Spring + JSP 코드 패턴 기반 REST API 연동 - * - 실제 데이터베이스와 연동되어 작동 - */ -export function UserManagement() { - const { - // 데이터 - users, - searchFilter, - isLoading, - isSearching, - error, - paginationInfo, - - // 검색 기능 - updateSearchFilter, - - // 페이지네이션 - handlePageChange, - handlePageSizeChange, - - // 액션 핸들러 - handleStatusToggle, - - // 유틸리티 - clearError, - refreshData, - } = useUserManagement(); - - // 비밀번호 초기화 모달 상태 - const [passwordResetModal, setPasswordResetModal] = useState({ - isOpen: false, - userId: null as string | null, - userName: null as string | null, - }); - - // 사용자 등록/수정 모달 상태 - const [userFormModal, setUserFormModal] = useState({ - isOpen: false, - editingUser: null as any | null, - }); - - // 사용자 등록 핸들러 - const handleCreateUser = () => { - setUserFormModal({ - isOpen: true, - editingUser: null, - }); - }; - - // 사용자 수정 핸들러 - const handleEditUser = (user: any) => { - setUserFormModal({ - isOpen: true, - editingUser: user, - }); - }; - - // 사용자 등록/수정 모달 닫기 - const handleUserFormClose = () => { - setUserFormModal({ - isOpen: false, - editingUser: null, - }); - }; - - // 사용자 등록/수정 성공 핸들러 - const handleUserFormSuccess = () => { - refreshData(); // 목록 새로고침 - handleUserFormClose(); - }; - - // 비밀번호 초기화 핸들러 - const handlePasswordReset = (userId: string, userName: string) => { - setPasswordResetModal({ - isOpen: true, - userId, - userName, - }); - }; - - // 비밀번호 초기화 모달 닫기 - const handlePasswordResetClose = () => { - setPasswordResetModal({ - isOpen: false, - userId: null, - userName: null, - }); - }; - - // 비밀번호 초기화 성공 핸들러 - const handlePasswordResetSuccess = () => { - // refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요 - handlePasswordResetClose(); - }; - - return ( -
- {/* 툴바 - 검색, 필터, 등록 버튼 */} - - - {/* 에러 메시지 */} - {error && ( -
-
-

오류가 발생했습니다

- -
-

{error}

-
- )} - - {/* 사용자 목록 테이블 */} - - - {/* 페이지네이션 */} - {!isLoading && users.length > 0 && ( - - )} - - {/* 사용자 등록/수정 모달 */} - - - {/* 비밀번호 초기화 모달 */} - -
- ); -} diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/components/admin/department/DepartmentManagement.tsx deleted file mode 100644 index 4939e24e..00000000 --- a/frontend/components/admin/department/DepartmentManagement.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ArrowLeft } from "lucide-react"; -import { DepartmentStructure } from "./DepartmentStructure"; -import { DepartmentMembers } from "./DepartmentMembers"; -import type { Department } from "@/types/department"; -import { getCompanyList } from "@/lib/api/company"; - -interface DepartmentManagementProps { - companyCode: string; -} - -/** - * 부서 관리 메인 컴포넌트 - * 좌측: 부서 구조, 우측: 부서 인원 - */ -export function DepartmentManagement({ companyCode }: DepartmentManagementProps) { - const router = useRouter(); - const [selectedDepartment, setSelectedDepartment] = useState(null); - const [activeTab, setActiveTab] = useState("structure"); - const [companyName, setCompanyName] = useState(""); - const [refreshTrigger, setRefreshTrigger] = useState(0); - - // 부서원 변경 시 부서 구조 새로고침 - const handleMemberChange = () => { - setRefreshTrigger((prev) => prev + 1); - }; - - // 회사 정보 로드 - useEffect(() => { - const loadCompanyInfo = async () => { - const response = await getCompanyList(); - if (response.success && response.data) { - const company = response.data.find((c) => c.company_code === companyCode); - if (company) { - setCompanyName(company.company_name); - } - } - }; - loadCompanyInfo(); - }, [companyCode]); - - const handleBackToList = () => { - router.push("/admin/userMng/companyList"); - }; - - return ( -
- {/* 상단 헤더: 회사 정보 + 뒤로가기 */} -
-
- -
-
-

{companyName || companyCode}

-

부서 관리

-
-
-
- {/* 탭 네비게이션 (모바일용) */} -
- - - 부서 구조 - 부서 인원 - - - - - - - - - - -
- - {/* 좌우 레이아웃 (데스크톱) */} -
- {/* 좌측: 부서 구조 (20%) */} -
- -
- - {/* 우측: 부서 인원 (80%) */} -
- -
-
-
- ); -} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 449a9c49..b28e4d01 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -17,12 +17,14 @@ import { UserCheck, LogOut, User, + Building2, } from "lucide-react"; import { useMenu } from "@/contexts/MenuContext"; import { useAuth } from "@/hooks/useAuth"; import { useProfile } from "@/hooks/useProfile"; import { MenuItem } from "@/lib/api/menu"; import { menuScreenApi } from "@/lib/api/screen"; +import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { ProfileModal } from "./ProfileModal"; import { Logo } from "./Logo"; @@ -35,6 +37,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { CompanySwitcher } from "@/components/admin/CompanySwitcher"; // useAuth의 UserInfo 타입을 확장 interface ExtendedUserInfo { @@ -206,11 +216,38 @@ function AppLayoutInner({ children }: AppLayoutProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const { user, logout, refreshUserData } = useAuth(); + const { user, logout, refreshUserData, switchCompany } = useAuth(); const { userMenus, adminMenus, loading, refreshMenus } = useMenu(); const [sidebarOpen, setSidebarOpen] = useState(true); const [expandedMenus, setExpandedMenus] = useState>(new Set()); const [isMobile, setIsMobile] = useState(false); + const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); + const [currentCompanyName, setCurrentCompanyName] = useState(""); + + // 현재 회사명 조회 (SUPER_ADMIN 전용) + useEffect(() => { + const fetchCurrentCompanyName = async () => { + if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") { + const companyCode = (user as ExtendedUserInfo)?.companyCode; + + if (companyCode === "*") { + setCurrentCompanyName("WACE (최고 관리자)"); + } else if (companyCode) { + try { + const response = await apiClient.get("/admin/companies/db"); + if (response.data.success) { + const company = response.data.data.find((c: any) => c.company_code === companyCode); + setCurrentCompanyName(company?.company_name || companyCode); + } + } catch (error) { + setCurrentCompanyName(companyCode); + } + } + } + }; + + fetchCurrentCompanyName(); + }, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]); // 화면 크기 감지 및 사이드바 초기 상태 설정 useEffect(() => { @@ -333,11 +370,32 @@ function AppLayoutInner({ children }: AppLayoutProps) { }; // 모드 전환 핸들러 - const handleModeSwitch = () => { + const handleModeSwitch = async () => { if (isAdminMode) { + // 관리자 → 사용자 모드: 선택한 회사 유지 router.push("/main"); } else { - router.push("/admin"); + // 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만) + if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") { + const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode; + + // 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동 + if (currentCompanyCode !== "*") { + const result = await switchCompany("*"); + if (result.success) { + // 페이지 새로고침 (관리자 페이지로 이동) + window.location.href = "/admin"; + } else { + toast.error("WACE로 전환 실패"); + } + } else { + // 이미 WACE면 바로 관리자 페이지로 이동 + router.push("/admin"); + } + } else { + // 일반 관리자는 바로 관리자 페이지로 이동 + router.push("/admin"); + } } }; @@ -498,11 +556,27 @@ function AppLayoutInner({ children }: AppLayoutProps) {
)} + {/* WACE 관리자: 현재 관리 회사 표시 */} + {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && ( +
+
+ +
+

현재 관리 회사

+

+ {currentCompanyName || "로딩 중..."} +

+
+
+
+ )} + {/* Admin/User 모드 전환 버튼 (관리자만) */} {((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" || (user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" || (user as ExtendedUserInfo)?.userType === "admin") && ( -
+
+ {/* 관리자/사용자 메뉴 전환 */} + + {/* WACE 관리자 전용: 회사 선택 버튼 */} + {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && ( + + )}
)} @@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) { onSave={saveProfile} onAlertClose={closeAlert} /> + + {/* 회사 전환 모달 (WACE 관리자 전용) */} + + + + 회사 선택 + + 관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다. + + +
+ setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} /> +
+
+
); } diff --git a/frontend/contexts/MenuContext.tsx b/frontend/contexts/MenuContext.tsx index 1ced8546..88b15542 100644 --- a/frontend/contexts/MenuContext.tsx +++ b/frontend/contexts/MenuContext.tsx @@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from import type { MenuItem } from "@/lib/api/menu"; import { menuApi } from "@/lib/api/menu"; // API 호출 활성화 import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; // user 정보 가져오기 interface MenuContextType { adminMenus: MenuItem[]; @@ -18,6 +19,7 @@ export function MenuProvider({ children }: { children: ReactNode }) { const [adminMenus, setAdminMenus] = useState([]); const [userMenus, setUserMenus] = useState([]); const [loading, setLoading] = useState(true); + const { user } = useAuth(); // user 정보 가져오기 const convertMenuData = (data: any[]): MenuItem[] => { return data.map((item) => ({ @@ -96,8 +98,10 @@ export function MenuProvider({ children }: { children: ReactNode }) { }; useEffect(() => { + // user.companyCode가 변경되면 메뉴 다시 로드 + // console.log("🔄 MenuContext: user.companyCode 변경 감지, 메뉴 재로드", user?.companyCode); loadMenus(); - }, []); // 초기 로드만 + }, [user?.companyCode]); // companyCode 변경 시 재로드 return ( {children} diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 854a9196..29752559 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -331,6 +331,61 @@ export const useAuth = () => { [apiCall, refreshUserData], ); + /** + * 회사 전환 처리 (WACE 관리자 전용) + */ + const switchCompany = useCallback( + async (companyCode: string): Promise<{ success: boolean; message: string }> => { + try { + // console.log("🔵 useAuth.switchCompany 시작:", companyCode); + setLoading(true); + setError(null); + + // console.log("🔵 API 호출: POST /auth/switch-company"); + const response = await apiCall("POST", "/auth/switch-company", { + companyCode, + }); + // console.log("🔵 API 응답:", response); + + if (response.success && response.data?.token) { + // console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "..."); + + // 새로운 JWT 토큰 저장 + TokenManager.setToken(response.data.token); + // console.log("🔵 토큰 저장 완료"); + + // refreshUserData 호출하지 않고 바로 성공 반환 + // (페이지 새로고침 시 자동으로 갱신됨) + // console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)"); + + return { + success: true, + message: response.message || "회사 전환에 성공했습니다.", + }; + } else { + // console.error("🔵 API 응답 실패:", response); + return { + success: false, + message: response.message || "회사 전환에 실패했습니다.", + }; + } + } catch (error: any) { + // console.error("🔵 switchCompany 에러:", error); + const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다."; + setError(errorMessage); + + return { + success: false, + message: errorMessage, + }; + } finally { + setLoading(false); + // console.log("🔵 switchCompany 완료"); + } + }, + [apiCall] + ); + /** * 로그아웃 처리 */ @@ -493,6 +548,7 @@ export const useAuth = () => { // 함수 login, logout, + switchCompany, // 🆕 회사 전환 함수 checkMenuAuth, refreshUserData, diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 82ab39ac..67de76ae 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -85,9 +85,9 @@ export const menuApi = { return response.data; }, - // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) + // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시, 회사별 필터링) getUserMenus: async (): Promise> => { - const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } }); + const response = await apiClient.get("/admin/user-menus"); return response.data; }, From 7d6bff49aa7d737fb6bb6fa78490b99b2d595a0f Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 15:36:28 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=ED=8F=BC=20=EC=B1=84=EB=B2=88=20?= =?UTF-8?q?=EC=98=A4=EC=9E=91=EB=8F=99=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/utils/buttonActions.ts | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 9a6a606e..944f7126 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1608,6 +1608,66 @@ export class ButtonActionExecutor { return { handled: false, success: false }; } + // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) + console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작"); + + const fieldsWithNumbering: Record = {}; + + // commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기 + for (const [key, value] of Object.entries(modalData)) { + if (key.endsWith("_numberingRuleId") && value) { + const fieldName = key.replace("_numberingRuleId", ""); + fieldsWithNumbering[fieldName] = value as string; + console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`); + } + } + + // formData에서도 확인 (모달 외부에 있을 수 있음) + for (const [key, value] of Object.entries(formData)) { + if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) { + const fieldName = key.replace("_numberingRuleId", ""); + fieldsWithNumbering[fieldName] = value as string; + console.log( + `🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`, + ); + } + } + + console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering); + + // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가 + if (Object.keys(fieldsWithNumbering).length > 0) { + console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)"); + const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { + try { + console.log( + `🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`, + ); + const allocateResult = await allocateNumberingCode(ruleId); + + if (allocateResult.success && allocateResult.data?.generatedCode) { + const newCode = allocateResult.data.generatedCode; + console.log( + `✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`, + ); + commonFieldsData[fieldName] = newCode; + } else { + console.warn( + `⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`, + allocateResult.error, + ); + } + } catch (allocateError) { + console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError); + // 오류 시 기존 값 유지 + } + } + } + + console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료"); + try { // 사용자 정보 추가 if (!context.userId) { From bd49db16c6c55c226a28151491864797ae777203 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Dec 2025 17:45:38 +0900 Subject: [PATCH 11/12] 1 --- frontend/app/(main)/admin/systemMng/dataflow/page.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx index 87d937ec..d55a6cf1 100644 --- a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx +++ b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx @@ -51,17 +51,17 @@ export default function DataFlowPage() { // 에디터 모드일 때는 레이아웃 없이 전체 화면 사용 if (isEditorMode) { return ( -
+
{/* 에디터 헤더 */} -
+

노드 플로우 에디터

-

+

드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다

@@ -77,12 +77,12 @@ export default function DataFlowPage() { } return ( -
+
{/* 페이지 헤더 */}

제어 관리

-

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

+

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

{/* 플로우 목록 */} From 5bdc903b0dee0382f21de3792b916937cf36fe8c Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 31 Dec 2025 10:54:07 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=EB=B2=94=EC=9A=A9=20=ED=8F=BC=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=A0=9C=EC=96=B4=EB=A1=9C=EC=A7=81=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/dataflow/node-flows.ts | 67 ++++++++++++++++ frontend/lib/api/nodeFlows.ts | 38 +++++++++ frontend/lib/utils/buttonActions.ts | 78 +++++++++++++++++++ 3 files changed, 183 insertions(+) diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 6de84866..177b4304 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -214,6 +214,73 @@ router.delete("/:flowId", async (req: Request, res: Response) => { } }); +/** + * 플로우 소스 테이블 조회 + * GET /api/dataflow/node-flows/:flowId/source-table + * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출 + */ +router.get("/:flowId/source-table", async (req: Request, res: Response) => { + try { + const { flowId } = req.params; + + const flow = await queryOne<{ flow_data: any }>( + `SELECT flow_data FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + + if (!flow) { + return res.status(404).json({ + success: false, + message: "플로우를 찾을 수 없습니다.", + }); + } + + const flowData = + typeof flow.flow_data === "string" + ? JSON.parse(flow.flow_data) + : flow.flow_data; + + const nodes = flowData.nodes || []; + + // 소스 노드 찾기 (tableSource, externalDBSource 타입) + const sourceNode = nodes.find( + (node: any) => + node.type === "tableSource" || node.type === "externalDBSource" + ); + + if (!sourceNode || !sourceNode.data?.tableName) { + return res.json({ + success: true, + data: { + sourceTable: null, + sourceNodeType: null, + message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.", + }, + }); + } + + logger.info( + `플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}` + ); + + return res.json({ + success: true, + data: { + sourceTable: sourceNode.data.tableName, + sourceNodeType: sourceNode.type, + sourceNodeId: sourceNode.id, + displayName: sourceNode.data.displayName, + }, + }); + } catch (error) { + logger.error("플로우 소스 테이블 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "플로우 소스 테이블을 조회하지 못했습니다.", + }); + } +}); + /** * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute diff --git a/frontend/lib/api/nodeFlows.ts b/frontend/lib/api/nodeFlows.ts index b42340d7..27bb1b96 100644 --- a/frontend/lib/api/nodeFlows.ts +++ b/frontend/lib/api/nodeFlows.ts @@ -120,3 +120,41 @@ export interface NodeExecutionSummary { duration?: number; error?: string; } + +/** + * 플로우 소스 테이블 정보 인터페이스 + */ +export interface FlowSourceTableInfo { + sourceTable: string | null; + sourceNodeType: string | null; + sourceNodeId?: string; + displayName?: string; + message?: string; +} + +/** + * 플로우 소스 테이블 조회 + * 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출 + */ +export async function getFlowSourceTable(flowId: number): Promise { + try { + const response = await apiClient.get>( + `/dataflow/node-flows/${flowId}/source-table`, + ); + if (response.data.success && response.data.data) { + return response.data.data; + } + return { + sourceTable: null, + sourceNodeType: null, + message: response.data.message || "소스 테이블 정보를 가져올 수 없습니다.", + }; + } catch (error) { + console.error("플로우 소스 테이블 조회 실패:", error); + return { + sourceTable: null, + sourceNodeType: null, + message: "API 호출 중 오류가 발생했습니다.", + }; + } +} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 944f7126..327cb87f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1864,6 +1864,84 @@ export class ButtonActionExecutor { console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`); toast.success(`저장 완료: ${resultMessage}`); + // 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행) + if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) { + const flowId = config.dataflowConfig.flowConfig.flowId; + console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId }); + + try { + // 플로우 소스 테이블 조회 + const { getFlowSourceTable } = await import("@/lib/api/nodeFlows"); + const flowSourceInfo = await getFlowSourceTable(flowId); + + console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo); + + if (flowSourceInfo.sourceTable) { + // 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기 + let controlExecuted = false; + + for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) { + const sectionConfig = sections.find((s: any) => s.id === sectionId); + const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName; + + console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, { + sectionTargetTable, + flowSourceTable: flowSourceInfo.sourceTable, + isMatch: sectionTargetTable === flowSourceInfo.sourceTable, + }); + + // 소스 테이블과 일치하는 섹션만 제어 실행 + if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) { + console.log( + `✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`, + ); + + // 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성 + const sourceData = sectionItems.map((item: any) => ({ + ...commonFieldsData, + ...item, + })); + + console.log( + `📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`, + sourceData[0], + ); + + // 제어 관리용 컨텍스트 생성 + const controlContext: ButtonActionContext = { + ...context, + selectedRowsData: sourceData, + formData: commonFieldsData, + }; + + // 제어 관리 실행 + await this.executeAfterSaveControl(config, controlContext); + controlExecuted = true; + break; // 첫 번째 매칭 섹션만 실행 + } + } + + // 매칭되는 섹션이 없으면 메인 테이블 확인 + if (!controlExecuted && tableName === flowSourceInfo.sourceTable) { + console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행"); + + const controlContext: ButtonActionContext = { + ...context, + selectedRowsData: [commonFieldsData], + formData: commonFieldsData, + }; + + await this.executeAfterSaveControl(config, controlContext); + } + } else { + console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵"); + } + } catch (controlError) { + console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError); + // 제어 관리 실패는 저장 성공에 영향주지 않음 + } + } + // 저장 성공 이벤트 발생 window.dispatchEvent(new CustomEvent("saveSuccess")); window.dispatchEvent(new CustomEvent("refreshTable"));