diff --git a/.cursor/rules/table-type-sql-guide.mdc b/.cursor/rules/table-type-sql-guide.mdc new file mode 100644 index 00000000..3c53c537 --- /dev/null +++ b/.cursor/rules/table-type-sql-guide.mdc @@ -0,0 +1,592 @@ +# 테이블 타입 관리 SQL 작성 가이드 + +테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다. + +## 핵심 원칙 + +1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)` +2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등 +3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수 +4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns` + +--- + +## 1. 테이블 생성 DDL 템플릿 + +### 기본 구조 + +```sql +CREATE TABLE "테이블명" ( + -- 시스템 기본 컬럼 (자동 포함) + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + + -- 사용자 정의 컬럼 (모두 VARCHAR(500)) + "컬럼1" varchar(500), + "컬럼2" varchar(500), + "컬럼3" varchar(500) +); +``` + +### 예시: 고객 테이블 생성 + +```sql +CREATE TABLE "customer_info" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + + "customer_name" varchar(500), + "customer_code" varchar(500), + "phone" varchar(500), + "email" varchar(500), + "address" varchar(500), + "status" varchar(500), + "registration_date" varchar(500) +); +``` + +--- + +## 2. 메타데이터 테이블 등록 + +테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다. + +### 2.1 table_labels (테이블 메타데이터) + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET + table_label = EXCLUDED.table_label, + description = EXCLUDED.description, + updated_date = now(); +``` + +### 2.2 table_type_columns (컬럼 타입 정보) + +**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order` + +```sql +-- 기본 컬럼 등록 (display_order: -5 ~ -1) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()), + ('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()), + ('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()), + ('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()), + ('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET + input_type = EXCLUDED.input_type, + display_order = EXCLUDED.display_order, + updated_date = now(); + +-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()), + ('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()), + ('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET + input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, + display_order = EXCLUDED.display_order, + updated_date = now(); +``` + +### 2.3 column_labels (레거시 호환용 - 필수) + +```sql +-- 기본 컬럼 등록 +INSERT INTO column_labels ( + table_name, column_name, column_label, input_type, detail_settings, + description, display_order, is_visible, created_date, updated_date +) VALUES + ('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()), + ('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()), + ('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()), + ('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()), + ('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now()) +ON CONFLICT (table_name, column_name) +DO UPDATE SET + column_label = EXCLUDED.column_label, + input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, + description = EXCLUDED.description, + display_order = EXCLUDED.display_order, + is_visible = EXCLUDED.is_visible, + updated_date = now(); + +-- 사용자 정의 컬럼 등록 +INSERT INTO column_labels ( + table_name, column_name, column_label, input_type, detail_settings, + description, display_order, is_visible, created_date, updated_date +) VALUES + ('테이블명', '컬럼1', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()), + ('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now()) +ON CONFLICT (table_name, column_name) +DO UPDATE SET + column_label = EXCLUDED.column_label, + input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, + description = EXCLUDED.description, + display_order = EXCLUDED.display_order, + is_visible = EXCLUDED.is_visible, + updated_date = now(); +``` + +--- + +## 3. Input Type 정의 + +### 지원되는 Input Type 목록 + +| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 | +| ---------- | ------------- | ------------ | -------------------- | +| `text` | 텍스트 입력 | VARCHAR(500) | Input | +| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) | +| `date` | 날짜/시간 | VARCHAR(500) | DatePicker | +| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) | +| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) | +| `select` | 선택 목록 | VARCHAR(500) | Select | +| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox | +| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup | +| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea | +| `file` | 파일 업로드 | VARCHAR(500) | FileUpload | + +### WebType → InputType 변환 규칙 + +``` +text, textarea, email, tel, url, password → text +number, decimal → number +date, datetime, time → date +select, dropdown → select +checkbox, boolean → checkbox +radio → radio +code → code +entity → entity +file → text +button → text +``` + +--- + +## 4. Detail Settings 설정 + +### 4.1 Code 타입 (공통코드 참조) + +```json +{ + "codeCategory": "코드_카테고리_ID" +} +``` + +```sql +INSERT INTO table_type_columns (..., input_type, detail_settings, ...) +VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...); +``` + +### 4.2 Entity 타입 (테이블 참조) + +```json +{ + "referenceTable": "참조_테이블명", + "referenceColumn": "참조_컬럼명(보통 id)", + "displayColumn": "표시할_컬럼명" +} +``` + +```sql +INSERT INTO table_type_columns (..., input_type, detail_settings, ...) +VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...); +``` + +### 4.3 Select 타입 (정적 옵션) + +```json +{ + "options": [ + { "label": "옵션1", "value": "value1" }, + { "label": "옵션2", "value": "value2" } + ] +} +``` + +--- + +## 5. 전체 예시: 주문 테이블 생성 + +### Step 1: DDL 실행 + +```sql +CREATE TABLE "order_info" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + + "order_no" varchar(500), + "order_date" varchar(500), + "customer_id" varchar(500), + "total_amount" varchar(500), + "status" varchar(500), + "notes" varchar(500) +); +``` + +### Step 2: table_labels 등록 + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET + table_label = EXCLUDED.table_label, + description = EXCLUDED.description, + updated_date = now(); +``` + +### Step 3: table_type_columns 등록 + +```sql +-- 기본 컬럼 +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date) +VALUES + ('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()), + ('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()), + ('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()), + ('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()), + ('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now(); + +-- 사용자 정의 컬럼 +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date) +VALUES + ('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()), + ('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()), + ('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()), + ('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()), + ('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()), + ('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now(); +``` + +### Step 4: column_labels 등록 (레거시 호환) + +```sql +-- 기본 컬럼 +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date) +VALUES + ('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()), + ('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()), + ('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()), + ('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()), + ('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now()) +ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now(); + +-- 사용자 정의 컬럼 +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date) +VALUES + ('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()), + ('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()), + ('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()), + ('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()), + ('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()), + ('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now()) +ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now(); +``` + +--- + +## 6. 컬럼 추가 시 + +### DDL + +```sql +ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500); +``` + +### 메타데이터 등록 + +```sql +-- table_type_columns +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date) +VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now(); + +-- column_labels +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date) +VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now()) +ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now(); +``` + +--- + +## 7. 로그 테이블 생성 (선택사항) + +변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다. + +### 7.1 로그 테이블 DDL 템플릿 + +```sql +-- 로그 테이블 생성 +CREATE TABLE 테이블명_log ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE + original_id VARCHAR(100), -- 원본 테이블 PK 값 + changed_column VARCHAR(100), -- 변경된 컬럼명 + old_value TEXT, -- 변경 전 값 + new_value TEXT, -- 변경 후 값 + changed_by VARCHAR(50), -- 변경자 ID + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각 + ip_address VARCHAR(50), -- 변경 요청 IP + user_agent TEXT, -- User Agent + full_row_before JSONB, -- 변경 전 전체 행 + full_row_after JSONB -- 변경 후 전체 행 +); + +-- 인덱스 생성 +CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id); +CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at); +CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type); + +-- 코멘트 추가 +COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력'; +``` + +### 7.2 트리거 함수 DDL 템플릿 + +```sql +CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func() +RETURNS TRIGGER AS $$ +DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); +BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb); + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '테이블명' + AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO 테이블명_log ( + operation_type, original_id, changed_column, old_value, new_value, + changed_by, ip_address, full_row_before, full_row_after + ) + VALUES ( + 'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, + v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb + ); + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb); + RETURN OLD; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +``` + +### 7.3 트리거 DDL 템플릿 + +```sql +CREATE TRIGGER 테이블명_audit_trigger +AFTER INSERT OR UPDATE OR DELETE ON 테이블명 +FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func(); +``` + +### 7.4 로그 설정 등록 + +```sql +INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, is_active, created_by, created_at +) VALUES ( + '테이블명', '테이블명_log', '테이블명_audit_trigger', + '테이블명_log_trigger_func', 'Y', '생성자ID', now() +); +``` + +### 7.5 table_labels에 use_log_table 플래그 설정 + +```sql +UPDATE table_labels +SET use_log_table = 'Y', updated_date = now() +WHERE table_name = '테이블명'; +``` + +### 7.6 전체 예시: order_info 로그 테이블 생성 + +```sql +-- Step 1: 로그 테이블 생성 +CREATE TABLE order_info_log ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id VARCHAR(100), + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB +); + +CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id); +CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at); +CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type); + +COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력'; + +-- Step 2: 트리거 함수 생성 +CREATE OR REPLACE FUNCTION order_info_log_trigger_func() +RETURNS TRIGGER AS $$ +DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); +BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name FROM information_schema.columns + WHERE table_name = 'order_info' AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value USING OLD, NEW; + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after) + VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb); + END IF; + END LOOP; + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb); + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Step 3: 트리거 생성 +CREATE TRIGGER order_info_audit_trigger +AFTER INSERT OR UPDATE OR DELETE ON order_info +FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func(); + +-- Step 4: 로그 설정 등록 +INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at) +VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now()); + +-- Step 5: table_labels 플래그 업데이트 +UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info'; +``` + +### 7.7 로그 테이블 삭제 + +```sql +-- 트리거 삭제 +DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명; + +-- 트리거 함수 삭제 +DROP FUNCTION IF EXISTS 테이블명_log_trigger_func(); + +-- 로그 테이블 삭제 +DROP TABLE IF EXISTS 테이블명_log; + +-- 로그 설정 삭제 +DELETE FROM table_log_config WHERE original_table_name = '테이블명'; + +-- table_labels 플래그 업데이트 +UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명'; +``` + +--- + +## 8. 체크리스트 + +### 테이블 생성/수정 시 반드시 확인할 사항: + +- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code) +- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용 +- [ ] `table_labels`에 테이블 메타데이터 등록 +- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*') +- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환) +- [ ] 기본 컬럼 display_order: -5 ~ -1 +- [ ] 사용자 정의 컬럼 display_order: 0부터 순차 +- [ ] code/entity 타입은 detail_settings에 참조 정보 포함 +- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리 + +### 로그 테이블 생성 시 확인할 사항 (선택): + +- [ ] 로그 테이블 생성 (`테이블명_log`) +- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type) +- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`) +- [ ] 트리거 생성 (`테이블명_audit_trigger`) +- [ ] `table_log_config`에 로그 설정 등록 +- [ ] `table_labels.use_log_table = 'Y'` 업데이트 + +--- + +## 9. 금지 사항 + +1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지 +2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용 +3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수 +4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수 +5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용 + +--- + +## 참조 파일 + +- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스 +- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스 +- `backend-node/src/types/ddl.ts`: DDL 타입 정의 +- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러 +- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러 diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png new file mode 100644 index 00000000..0fad6fa6 Binary files /dev/null and b/.playwright-mcp/pivotgrid-demo.png differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png new file mode 100644 index 00000000..79041f47 Binary files /dev/null and b/.playwright-mcp/pivotgrid-table.png differ diff --git a/.playwright-mcp/pop-page-initial.png b/.playwright-mcp/pop-page-initial.png new file mode 100644 index 00000000..b14666b3 Binary files /dev/null and b/.playwright-mcp/pop-page-initial.png differ diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index f826a86a..7e1108c3 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -3214,6 +3215,16 @@ "@types/node": "*" } }, + "node_modules/@types/bwip-js": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz", + "integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e9ce3729..b1bfa319 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b3d84ecb..29491bbc 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 +import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) @@ -220,6 +221,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/screen-files", screenFileRoutes); app.use("/api/batch-configs", batchRoutes); +app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿 app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes); // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 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/controllers/codeMergeController.ts b/backend-node/src/controllers/codeMergeController.ts index 29abfa8e..74d9e893 100644 --- a/backend-node/src/controllers/codeMergeController.ts +++ b/backend-node/src/controllers/codeMergeController.ts @@ -282,3 +282,175 @@ export async function previewCodeMerge( } } +/** + * 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경 + * 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경 + */ +export async function mergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue, newValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + // 입력값 검증 + if (!oldValue || !newValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue, newValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + // 같은 값으로 병합 시도 방지 + if (oldValue === newValue) { + res.status(400).json({ + success: false, + message: "기존 값과 새 값이 동일합니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 시작", { + oldValue, + newValue, + companyCode, + userId: req.user?.userId, + }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM merge_code_by_value($1, $2, $3)", + [oldValue, newValue, companyCode] + ); + + // 결과 처리 + const affectedData = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = affectedData.reduce( + (sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0), + 0 + ); + + logger.info("값 기반 코드 병합 완료", { + oldValue, + newValue, + affectedTablesCount: affectedData.length, + totalRowsUpdated: totalRows, + }); + + res.json({ + success: true, + message: `코드 병합 완료: ${oldValue} → ${newValue}`, + data: { + oldValue, + newValue, + affectedData: affectedData.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + rowsUpdated: parseInt(row.out_rows_updated), + })), + totalRowsUpdated: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 실패:", { + error: error.message, + stack: error.stack, + oldValue, + newValue, + }); + + res.status(500).json({ + success: false, + message: "코드 병합 중 오류가 발생했습니다.", + error: { + code: "CODE_MERGE_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 값 기반 코드 병합 미리보기 + * 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회 + */ +export async function previewMergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + if (!oldValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM preview_merge_code_by_value($1, $2)", + [oldValue, companyCode] + ); + + const preview = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = preview.reduce( + (sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0), + 0 + ); + + logger.info("값 기반 코드 병합 미리보기 완료", { + tablesCount: preview.length, + totalRows, + }); + + res.json({ + success: true, + message: "코드 병합 미리보기 완료", + data: { + oldValue, + preview: preview.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + affectedRows: parseInt(row.out_affected_rows), + })), + totalAffectedRows: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 미리보기 실패:", error); + + res.status(500).json({ + success: false, + message: "코드 병합 미리보기 중 오류가 발생했습니다.", + error: { + code: "PREVIEW_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 98606f51..48b55d18 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -231,7 +231,7 @@ export const deleteFormData = async ( try { const { id } = req.params; const { companyCode, userId } = req.user as any; - const { tableName } = req.body; + const { tableName, screenId } = req.body; if (!tableName) { return res.status(400).json({ @@ -240,7 +240,16 @@ export const deleteFormData = async ( }); } - await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가 + // screenId를 숫자로 변환 (문자열로 전달될 수 있음) + const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined; + + await dynamicFormService.deleteFormData( + id, + tableName, + companyCode, + userId, + parsedScreenId // screenId 추가 (제어관리 실행용) + ); res.json({ success: true, diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index fbb88750..013b2034 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -30,6 +30,7 @@ export class EntityJoinController { autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 + deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -139,6 +140,24 @@ export class EntityJoinController { } } + // 🆕 중복 제거 설정 처리 + let parsedDeduplication: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } | undefined = undefined; + if (deduplication) { + try { + parsedDeduplication = + typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; + logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); + } catch (error) { + logger.warn("중복 제거 설정 파싱 오류:", error); + parsedDeduplication = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -156,13 +175,26 @@ export class EntityJoinController { screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 + deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); + // 🆕 중복 제거 처리 (결과 데이터에 적용) + let finalData = result; + if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { + logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); + const originalCount = result.data.length; + finalData = { + ...result, + data: this.deduplicateData(result.data, parsedDeduplication), + }; + logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); + } + res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", - data: result, + data: finalData, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); @@ -537,6 +569,98 @@ export class EntityJoinController { }); } } + + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + private deduplicateData( + data: any[], + config: { + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + ): any[] { + if (!data || data.length === 0) return data; + + // 그룹별로 데이터 분류 + const groups: Record = {}; + + for (const row of data) { + const groupKey = row[config.groupByColumn]; + if (groupKey === undefined || groupKey === null) continue; + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(row); + } + + // 각 그룹에서 하나의 행만 선택 + const result: any[] = []; + + for (const [groupKey, rows] of Object.entries(groups)) { + if (rows.length === 0) continue; + + let selectedRow: any; + + switch (config.keepStrategy) { + case "latest": + // 정렬 컬럼 기준 최신 (가장 큰 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal > bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "earliest": + // 정렬 컬럼 기준 최초 (가장 작은 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal < bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "base_price": + // base_price가 true인 행 선택 + selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0]; + break; + + case "current_date": + // 오늘 날짜 기준 유효 기간 내 행 선택 + const today = new Date().toISOString().split("T")[0]; + selectedRow = rows.find((r) => { + const startDate = r.start_date; + const endDate = r.end_date; + if (!startDate) return true; + if (startDate <= today && (!endDate || endDate >= today)) return true; + return false; + }) || rows[0]; + break; + + default: + selectedRow = rows[0]; + } + + if (selectedRow) { + result.push(selectedRow); + } + } + + return result; + } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 4d911c57..5f198c3f 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { } // 추가 필터 조건 (존재하는 컬럼만) + // 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like + // 특수 키 형식: column__operator (예: division__in, name__like) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { - if (existingColumns.has(key)) { - whereConditions.push(`${key} = $${paramIndex}`); - params.push(value); - paramIndex++; - } else { - logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); + // 특수 키 형식 파싱: column__operator + let columnName = key; + let operator = "="; + + if (key.includes("__")) { + const parts = key.split("__"); + columnName = parts[0]; + operator = parts[1] || "="; + } + + if (!existingColumns.has(columnName)) { + logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName }); + continue; + } + + // 연산자별 WHERE 조건 생성 + switch (operator) { + case "=": + whereConditions.push(`"${columnName}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${columnName}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + whereConditions.push(`"${columnName}" > $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "<": + whereConditions.push(`"${columnName}" < $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">=": + whereConditions.push(`"${columnName}" >= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "<=": + whereConditions.push(`"${columnName}" <= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": + // IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열 + const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inValues.length > 0) { + const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${columnName}" IN (${placeholders})`); + params.push(...inValues); + paramIndex += inValues.length; + } + break; + case "notIn": + // NOT IN 연산자 + const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInValues.length > 0) { + const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${columnName}" NOT IN (${placeholders})`); + params.push(...notInValues); + paramIndex += notInValues.length; + } + break; + case "like": + whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + default: + // 알 수 없는 연산자는 등호로 처리 + whereConditions.push(`"${columnName}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; } } diff --git a/backend-node/src/controllers/excelMappingController.ts b/backend-node/src/controllers/excelMappingController.ts new file mode 100644 index 00000000..e29d4fe2 --- /dev/null +++ b/backend-node/src/controllers/excelMappingController.ts @@ -0,0 +1,208 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import excelMappingService from "../services/excelMappingService"; +import { logger } from "../utils/logger"; + +/** + * 엑셀 컬럼 구조로 매핑 템플릿 조회 + * POST /api/excel-mapping/find + */ +export async function findMappingByColumns( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, excelColumns } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !excelColumns || !Array.isArray(excelColumns)) { + res.status(400).json({ + success: false, + message: "tableName과 excelColumns(배열)가 필요합니다.", + }); + return; + } + + logger.info("엑셀 매핑 템플릿 조회 요청", { + tableName, + excelColumns, + companyCode, + userId: req.user?.userId, + }); + + const template = await excelMappingService.findMappingByColumns( + tableName, + excelColumns, + companyCode + ); + + if (template) { + res.json({ + success: true, + data: template, + message: "기존 매핑 템플릿을 찾았습니다.", + }); + } else { + res.json({ + success: true, + data: null, + message: "일치하는 매핑 템플릿이 없습니다.", + }); + } + } catch (error: any) { + logger.error("매핑 템플릿 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 매핑 템플릿 저장 (UPSERT) + * POST /api/excel-mapping/save + */ +export async function saveMappingTemplate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, excelColumns, columnMappings } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!tableName || !excelColumns || !columnMappings) { + res.status(400).json({ + success: false, + message: "tableName, excelColumns, columnMappings가 필요합니다.", + }); + return; + } + + logger.info("엑셀 매핑 템플릿 저장 요청", { + tableName, + excelColumns, + columnMappings, + companyCode, + userId, + }); + + const template = await excelMappingService.saveMappingTemplate( + tableName, + excelColumns, + columnMappings, + companyCode, + userId + ); + + res.json({ + success: true, + data: template, + message: "매핑 템플릿이 저장되었습니다.", + }); + } catch (error: any) { + logger.error("매핑 템플릿 저장 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 저장 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 테이블의 매핑 템플릿 목록 조회 + * GET /api/excel-mapping/list/:tableName + */ +export async function getMappingTemplates( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName) { + res.status(400).json({ + success: false, + message: "tableName이 필요합니다.", + }); + return; + } + + logger.info("매핑 템플릿 목록 조회 요청", { + tableName, + companyCode, + }); + + const templates = await excelMappingService.getMappingTemplates( + tableName, + companyCode + ); + + res.json({ + success: true, + data: templates, + }); + } catch (error: any) { + logger.error("매핑 템플릿 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 매핑 템플릿 삭제 + * DELETE /api/excel-mapping/:id + */ +export async function deleteMappingTemplate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!id) { + res.status(400).json({ + success: false, + message: "id가 필요합니다.", + }); + return; + } + + logger.info("매핑 템플릿 삭제 요청", { + id, + companyCode, + }); + + const deleted = await excelMappingService.deleteMappingTemplate( + parseInt(id), + companyCode + ); + + if (deleted) { + res.json({ + success: true, + message: "매핑 템플릿이 삭제되었습니다.", + }); + } else { + res.status(404).json({ + success: false, + message: "삭제할 매핑 템플릿을 찾을 수 없습니다.", + }); + } + } catch (error: any) { + logger.error("매핑 템플릿 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 031a1506..ab7114a5 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq const companyCode = req.user!.companyCode; const { ruleId } = req.params; + logger.info("코드 할당 요청", { ruleId, companyCode }); + try { const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + logger.info("코드 할당 성공", { ruleId, allocatedCode }); return res.json({ success: true, data: { generatedCode: allocatedCode } }); } catch (error: any) { - logger.error("코드 할당 실패", { error: error.message }); + logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 04fa1add..65cd5f4c 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -775,7 +775,8 @@ export async function getTableData( const userField = autoFilter?.userField || "companyCode"; const userValue = (req.user as any)[userField]; - if (userValue) { + // 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능 + if (userValue && userValue !== "*") { enhancedSearch[filterColumn] = userValue; logger.info("🔍 현재 사용자 필터 적용:", { @@ -784,6 +785,10 @@ export async function getTableData( userValue, tableName, }); + } else if (userValue === "*") { + logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", { + tableName, + }); } else { logger.warn("⚠️ 사용자 정보 필드 값 없음:", { userField, @@ -792,6 +797,9 @@ export async function getTableData( } } + // 🆕 최종 검색 조건 로그 + logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch)); + // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), @@ -893,13 +901,23 @@ export async function addTableData( } // 데이터 추가 - await tableManagementService.addTableData(tableName, data); + const result = await tableManagementService.addTableData(tableName, data); logger.info(`테이블 데이터 추가 완료: ${tableName}`); - const response: ApiResponse = { + // 무시된 컬럼이 있으면 경고 정보 포함 + const response: ApiResponse<{ + skippedColumns?: string[]; + savedColumns?: string[]; + }> = { success: true, - message: "테이블 데이터를 성공적으로 추가했습니다.", + message: result.skippedColumns.length > 0 + ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` + : "테이블 데이터를 성공적으로 추가했습니다.", + data: { + skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined, + savedColumns: result.savedColumns, + }, }; res.status(201).json(response); @@ -1973,15 +1991,21 @@ export async function multiTableSave( for (const subTableConfig of subTables || []) { const { tableName, linkColumn, items, options } = subTableConfig; - if (!tableName || !items || items.length === 0) { - logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`); + // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 + const hasSaveMainAsFirst = options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { + logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); continue; } logger.info(`서브 테이블 ${tableName} 저장 시작:`, { - itemsCount: items.length, + itemsCount: items?.length || 0, linkColumn, options, + hasSaveMainAsFirst, }); // 기존 데이터 삭제 옵션 @@ -1999,7 +2023,15 @@ export async function multiTableSave( } // 메인 데이터도 서브 테이블에 저장 (옵션) - if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) { + // mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지) + logger.info(`saveMainAsFirst 옵션 확인:`, { + saveMainAsFirst: options?.saveMainAsFirst, + mainFieldMappings: options?.mainFieldMappings, + mainFieldMappingsLength: options?.mainFieldMappings?.length, + linkColumn, + mainDataKeys: Object.keys(mainData), + }); + if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; @@ -2153,3 +2185,67 @@ export async function multiTableSave( } } +/** + * 두 테이블 간의 엔티티 관계 자동 감지 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +export async function getTableEntityRelations( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { leftTable, rightTable } = req.query; + + logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`); + + if (!leftTable || !rightTable) { + const response: ApiResponse = { + success: false, + message: "leftTable과 rightTable 파라미터가 필요합니다.", + error: { + code: "MISSING_PARAMETERS", + details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const relations = await tableManagementService.detectTableEntityRelations( + String(leftTable), + String(rightTable) + ); + + logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + + const response: ApiResponse = { + success: true, + message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, + data: { + leftTable: String(leftTable), + rightTable: String(rightTable), + relations, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", + error: { + code: "ENTITY_RELATIONS_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + 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/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index a5107448..c1d69e9f 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -55,3 +55,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 22cd2d2b..bbc9384d 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -51,3 +51,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 79a1c6e8..35ced071 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -67,3 +67,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 352a05b5..29ac8ee4 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -55,3 +55,4 @@ export default router; + diff --git a/backend-node/src/routes/codeMergeRoutes.ts b/backend-node/src/routes/codeMergeRoutes.ts index 78cbd3e1..2cb41923 100644 --- a/backend-node/src/routes/codeMergeRoutes.ts +++ b/backend-node/src/routes/codeMergeRoutes.ts @@ -3,6 +3,8 @@ import { mergeCodeAllTables, getTablesWithColumn, previewCodeMerge, + mergeCodeByValue, + previewMergeCodeByValue, } from "../controllers/codeMergeController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -13,7 +15,7 @@ router.use(authenticateToken); /** * POST /api/code-merge/merge-all-tables - * 코드 병합 실행 (모든 관련 테이블에 적용) + * 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만) * Body: { columnName, oldValue, newValue } */ router.post("/merge-all-tables", mergeCodeAllTables); @@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn); /** * POST /api/code-merge/preview - * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + * 코드 병합 미리보기 (같은 컬럼명 기준) * Body: { columnName, oldValue } */ router.post("/preview", previewCodeMerge); +/** + * POST /api/code-merge/merge-by-value + * 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경) + * Body: { oldValue, newValue } + */ +router.post("/merge-by-value", mergeCodeByValue); + +/** + * POST /api/code-merge/preview-by-value + * 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색) + * Body: { oldValue } + */ +router.post("/preview-by-value", previewMergeCodeByValue); + export default router; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f87aa5d6..574f1cf8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,10 +1,262 @@ import express from "express"; import { dataService } from "../services/dataService"; +import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; const router = express.Router(); +// ================================ +// 마스터-디테일 엑셀 API +// ================================ + +/** + * 마스터-디테일 관계 정보 조회 + * GET /api/data/master-detail/relation/:screenId + */ +router.get( + "/master-detail/relation/:screenId", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId } = req.params; + + if (!screenId || isNaN(parseInt(screenId))) { + return res.status(400).json({ + success: false, + message: "유효한 screenId가 필요합니다.", + }); + } + + console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`); + + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.json({ + success: true, + data: null, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + console.log(`✅ 마스터-디테일 관계 발견:`, { + masterTable: relation.masterTable, + detailTable: relation.detailTable, + joinKey: relation.masterKeyColumn, + }); + + return res.json({ + success: true, + data: relation, + }); + } catch (error: any) { + console.error("마스터-디테일 관계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 다운로드 데이터 조회 + * POST /api/data/master-detail/download + */ +router.post( + "/master-detail/download", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, filters } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!screenId) { + return res.status(400).json({ + success: false, + message: "screenId가 필요합니다.", + }); + } + + console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. JOIN 데이터 조회 + const data = await masterDetailExcelService.getJoinedData( + relation, + companyCode, + filters + ); + + console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`); + + return res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error("마스터-디테일 다운로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 다운로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 업로드 + * POST /api/data/master-detail/upload + */ +router.post( + "/master-detail/upload", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, data } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!screenId || !data || !Array.isArray(data)) { + return res.status(400).json({ + success: false, + message: "screenId와 data 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. 데이터 업로드 + const result = await masterDetailExcelService.uploadJoinedData( + relation, + data, + companyCode, + userId + ); + + console.log(`✅ 마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 UI에서 선택 + * - 디테일 정보만 엑셀에서 업로드 + * - 채번 규칙을 통해 마스터 키 자동 생성 + * + * POST /api/data/master-detail/upload-simple + */ +router.post( + "/master-detail/upload-simple", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + if (!screenId || !detailData || !Array.isArray(detailData)) { + return res.status(400).json({ + success: false, + message: "screenId와 detailData 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`); + console.log(` 마스터 필드 값:`, masterFieldValues); + console.log(` 채번 규칙 ID:`, numberingRuleId); + console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음"); + + // 업로드 실행 + const result = await masterDetailExcelService.uploadSimple( + parseInt(screenId), + detailData, + masterFieldValues || {}, + numberingRuleId, + companyCode, + userId, + afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성) + afterUploadFlows // 업로드 후 제어 실행 (다중) + ); + + console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 간단 모드 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +// ================================ +// 기존 데이터 API +// ================================ + /** * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... @@ -698,6 +950,7 @@ router.post( try { const { tableName } = req.params; const filterConditions = req.body; + const userCompany = req.user?.companyCode; if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { return res.status(400).json({ @@ -706,11 +959,12 @@ router.post( }); } - console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany }); const result = await dataService.deleteGroupRecords( tableName, - filterConditions + filterConditions, + userCompany // 회사 코드 전달 ); if (!result.success) { 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/backend-node/src/routes/excelMappingRoutes.ts b/backend-node/src/routes/excelMappingRoutes.ts new file mode 100644 index 00000000..cbcecc15 --- /dev/null +++ b/backend-node/src/routes/excelMappingRoutes.ts @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + findMappingByColumns, + saveMappingTemplate, + getMappingTemplates, + deleteMappingTemplate, +} from "../controllers/excelMappingController"; + +const router = Router(); + +// 엑셀 컬럼 구조로 매핑 템플릿 조회 +router.post("/find", authenticateToken, findMappingByColumns); + +// 매핑 템플릿 저장 (UPSERT) +router.post("/save", authenticateToken, saveMappingTemplate); + +// 테이블의 매핑 템플릿 목록 조회 +router.get("/list/:tableName", authenticateToken, getMappingTemplates); + +// 매핑 템플릿 삭제 +router.delete("/:id", authenticateToken, deleteMappingTemplate); + +export default router; + diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d0716d59..fa7832ee 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -38,6 +39,15 @@ router.use(authenticateToken); */ router.get("/tables", getTableList); +/** + * 두 테이블 간 엔티티 관계 조회 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +router.get("/tables/entity-relations", getTableEntityRelations); + /** * 테이블 컬럼 정보 조회 * GET /api/table-management/tables/:tableName/columns diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 5ca6b392..95d8befa 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -65,6 +65,13 @@ export class AdminService { } ); + // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 + // TODO: 권한 체크 다시 활성화 필요 + logger.info( + `⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` + ); + + /* [원본 코드 - 권한 그룹 체크] if (userType === "COMPANY_ADMIN") { // 회사 관리자: 권한 그룹 기반 필터링 적용 if (userRoleGroups.length > 0) { @@ -141,6 +148,7 @@ export class AdminService { return []; } } + */ } else if ( menuType !== undefined && userType === "SUPER_ADMIN" && @@ -412,9 +420,18 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { - // SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시 - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 + // TODO: 권한 체크 다시 활성화 필요 + logger.info( + `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` + ); + authFilter = ""; + unionFilter = ""; + + /* [원본 코드 - getUserMenuList 권한 그룹 체크] + if (userType === "SUPER_ADMIN") { + // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시 + logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`); authFilter = ""; unionFilter = ""; } else { @@ -471,6 +488,7 @@ export class AdminService { return []; } } + */ // 2. 회사별 필터링 조건 생성 let companyFilter = ""; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index a1a494f2..75c57673 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1189,6 +1189,13 @@ class DataService { [tableName] ); + console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, { + pkColumns: pkResult.map((r) => r.attname), + pkCount: pkResult.length, + inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id, + inputIdType: typeof id, + }); + let whereClauses: string[] = []; let params: any[] = []; @@ -1216,17 +1223,31 @@ class DataService { params.push(typeof id === "object" ? id[pkColumn] : id); } - const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; + const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`; console.log(`🗑️ 삭제 쿼리:`, queryText, params); const result = await query(queryText, params); + // 삭제된 행이 없으면 실패 처리 + if (result.length === 0) { + console.warn( + `⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`, + { whereClauses, params } + ); + return { + success: false, + message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + console.log( `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` ); return { success: true, + data: result[0], // 삭제된 레코드 정보 반환 }; } catch (error) { console.error(`레코드 삭제 오류 (${tableName}):`, error); @@ -1240,10 +1261,14 @@ class DataService { /** * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + * @param tableName 테이블명 + * @param filterConditions 삭제 조건 + * @param userCompany 사용자 회사 코드 (멀티테넌시 필터링) */ async deleteGroupRecords( tableName: string, - filterConditions: Record + filterConditions: Record, + userCompany?: string ): Promise> { try { const validation = await this.validateTableAccess(tableName); @@ -1255,6 +1280,7 @@ class DataService { const whereValues: any[] = []; let paramIndex = 1; + // 사용자 필터 조건 추가 for (const [key, value] of Object.entries(filterConditions)) { whereConditions.push(`"${key}" = $${paramIndex}`); whereValues.push(value); @@ -1269,10 +1295,24 @@ class DataService { }; } + // 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외) + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode && userCompany && userCompany !== "*") { + whereConditions.push(`"company_code" = $${paramIndex}`); + whereValues.push(userCompany); + paramIndex++; + console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`); + } + const whereClause = whereConditions.join(" AND "); const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; - console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { + tableName, + conditions: filterConditions, + userCompany, + whereClause, + }); const result = await pool.query(deleteQuery, whereValues); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 68c30252..89d96859 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,6 +1,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; +import tableCategoryValueService from "./tableCategoryValueService"; export interface FormDataResult { id: number; @@ -427,6 +428,24 @@ export class DynamicFormService { dataToInsert, }); + // 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원) + console.log("🏷️ 카테고리 라벨→코드 변환 시작..."); + const companyCodeForCategory = company_code || "*"; + const { convertedData: categoryConvertedData, conversions } = + await tableCategoryValueService.convertCategoryLabelsToCodesForData( + tableName, + companyCodeForCategory, + dataToInsert + ); + + if (conversions.length > 0) { + console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions); + // 변환된 데이터로 교체 + Object.assign(dataToInsert, categoryConvertedData); + } else { + console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)"); + } + // 테이블 컬럼 정보 조회하여 타입 변환 적용 console.log("🔍 테이블 컬럼 정보 조회 중..."); const columnInfo = await this.getTableColumnInfo(tableName); @@ -1173,12 +1192,18 @@ export class DynamicFormService { /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) + * @param id 삭제할 레코드 ID + * @param tableName 테이블명 + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param screenId 화면 ID (제어관리 실행용, 선택사항) */ async deleteFormData( id: string | number, tableName: string, companyCode?: string, - userId?: string + userId?: string, + screenId?: number ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { @@ -1291,14 +1316,19 @@ export class DynamicFormService { const recordCompanyCode = deletedRecord?.company_code || companyCode || "*"; - await this.executeDataflowControlIfConfigured( - 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) - tableName, - deletedRecord, - "delete", - userId || "system", - recordCompanyCode - ); + // screenId가 전달되지 않으면 제어관리를 실행하지 않음 + if (screenId && screenId > 0) { + await this.executeDataflowControlIfConfigured( + screenId, + tableName, + deletedRecord, + "delete", + userId || "system", + recordCompanyCode + ); + } else { + console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")"); + } } } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1643,10 +1673,16 @@ export class DynamicFormService { !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); - // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 + // 버튼 컴포넌트이고 제어관리가 활성화된 경우 + // triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete + const buttonActionType = properties?.componentConfig?.action?.type; + const isMatchingAction = + (triggerType === "delete" && buttonActionType === "delete") || + ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); + if ( properties?.componentType === "button-primary" && - properties?.componentConfig?.action?.type === "save" && + isMatchingAction && properties?.webTypeConfig?.enableDataflowControl === true ) { const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; diff --git a/backend-node/src/services/excelMappingService.ts b/backend-node/src/services/excelMappingService.ts new file mode 100644 index 00000000..a63a027b --- /dev/null +++ b/backend-node/src/services/excelMappingService.ts @@ -0,0 +1,283 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import crypto from "crypto"; + +export interface ExcelMappingTemplate { + id?: number; + tableName: string; + excelColumns: string[]; + excelColumnsHash: string; + columnMappings: Record; // { "엑셀컬럼": "시스템컬럼" } + companyCode: string; + createdDate?: Date; + updatedDate?: Date; +} + +class ExcelMappingService { + /** + * 엑셀 컬럼 목록으로 해시 생성 + * 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별 + */ + generateColumnsHash(columns: string[]): string { + // 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성 + const sortedColumns = [...columns].sort(); + const columnsString = sortedColumns.join("|"); + return crypto.createHash("md5").update(columnsString).digest("hex"); + } + + /** + * 엑셀 컬럼 구조로 매핑 템플릿 조회 + * 동일한 컬럼 구조가 있으면 기존 매핑 반환 + */ + async findMappingByColumns( + tableName: string, + excelColumns: string[], + companyCode: string + ): Promise { + try { + const hash = this.generateColumnsHash(excelColumns); + + logger.info("엑셀 매핑 템플릿 조회", { + tableName, + excelColumns, + hash, + companyCode, + }); + + const pool = getPool(); + + // 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회 + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND excel_columns_hash = $2 + ORDER BY updated_date DESC + LIMIT 1 + `; + params = [tableName, hash]; + } else { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND excel_columns_hash = $2 + AND (company_code = $3 OR company_code = '*') + ORDER BY + CASE WHEN company_code = $3 THEN 0 ELSE 1 END, + updated_date DESC + LIMIT 1 + `; + params = [tableName, hash, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rows.length > 0) { + logger.info("기존 매핑 템플릿 발견", { + id: result.rows[0].id, + tableName, + }); + return result.rows[0]; + } + + logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash }); + return null; + } catch (error: any) { + logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 매핑 템플릿 저장 (UPSERT) + * 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입 + */ + async saveMappingTemplate( + tableName: string, + excelColumns: string[], + columnMappings: Record, + companyCode: string, + userId?: string + ): Promise { + try { + const hash = this.generateColumnsHash(excelColumns); + + logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", { + tableName, + excelColumns, + hash, + columnMappings, + companyCode, + }); + + const pool = getPool(); + + const query = ` + INSERT INTO excel_mapping_template ( + table_name, + excel_columns, + excel_columns_hash, + column_mappings, + company_code, + created_date, + updated_date + ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (table_name, excel_columns_hash, company_code) + DO UPDATE SET + column_mappings = EXCLUDED.column_mappings, + updated_date = NOW() + RETURNING + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + `; + + const result = await pool.query(query, [ + tableName, + excelColumns, + hash, + JSON.stringify(columnMappings), + companyCode, + ]); + + logger.info("매핑 템플릿 저장 완료", { + id: result.rows[0].id, + tableName, + hash, + }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 테이블의 모든 매핑 템플릿 조회 + */ + async getMappingTemplates( + tableName: string, + companyCode: string + ): Promise { + try { + logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + ORDER BY updated_date DESC + `; + params = [tableName]; + } else { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND (company_code = $2 OR company_code = '*') + ORDER BY updated_date DESC + `; + params = [tableName, companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName }); + + return result.rows; + } catch (error: any) { + logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 매핑 템플릿 삭제 + */ + async deleteMappingTemplate( + id: number, + companyCode: string + ): Promise { + try { + logger.info("매핑 템플릿 삭제", { id, companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `DELETE FROM excel_mapping_template WHERE id = $1`; + params = [id]; + } else { + query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount && result.rowCount > 0) { + logger.info("매핑 템플릿 삭제 완료", { id }); + return true; + } + + logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode }); + return false; + } catch (error: any) { + logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error }); + throw error; + } + } +} + +export default new ExcelMappingService(); + diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts new file mode 100644 index 00000000..4b1a7218 --- /dev/null +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -0,0 +1,868 @@ +/** + * 마스터-디테일 엑셀 처리 서비스 + * + * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 + * 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다. + */ + +import { query, queryOne, transaction, getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ================================ +// 인터페이스 정의 +// ================================ + +/** + * 마스터-디테일 관계 정보 + */ +export interface MasterDetailRelation { + masterTable: string; + detailTable: string; + masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no) + detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no) + masterColumns: ColumnInfo[]; + detailColumns: ColumnInfo[]; +} + +/** + * 컬럼 정보 + */ +export interface ColumnInfo { + name: string; + label: string; + inputType: string; + isFromMaster: boolean; +} + +/** + * 분할 패널 설정 + */ +export interface SplitPanelConfig { + leftPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + }; + rightPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + relation?: { + type: string; + foreignKey: string; + leftColumn: string; + }; + }; +} + +/** + * 엑셀 다운로드 결과 + */ +export interface ExcelDownloadData { + headers: string[]; // 컬럼 라벨들 + columns: string[]; // 컬럼명들 + data: Record[]; + masterColumns: string[]; // 마스터 컬럼 목록 + detailColumns: string[]; // 디테일 컬럼 목록 + joinKey: string; // 조인 키 +} + +/** + * 엑셀 업로드 결과 + */ +export interface ExcelUploadResult { + success: boolean; + masterInserted: number; + masterUpdated: number; + detailInserted: number; + detailDeleted: number; + errors: string[]; +} + +// ================================ +// 서비스 클래스 +// ================================ + +class MasterDetailExcelService { + + /** + * 화면 ID로 분할 패널 설정 조회 + */ + async getSplitPanelConfig(screenId: number): Promise { + try { + logger.info(`분할 패널 설정 조회: screenId=${screenId}`); + + // screen_layouts에서 split-panel-layout 컴포넌트 찾기 + const result = await queryOne( + `SELECT properties->>'componentConfig' as config + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties->>'componentType' = 'split-panel-layout' + LIMIT 1`, + [screenId] + ); + + if (!result || !result.config) { + logger.info(`분할 패널 없음: screenId=${screenId}`); + return null; + } + + const config = typeof result.config === "string" + ? JSON.parse(result.config) + : result.config; + + logger.info(`분할 패널 설정 발견:`, { + leftTable: config.leftPanel?.tableName, + rightTable: config.rightPanel?.tableName, + relation: config.rightPanel?.relation, + }); + + return { + leftPanel: config.leftPanel, + rightPanel: config.rightPanel, + }; + } catch (error: any) { + logger.error(`분할 패널 설정 조회 실패: ${error.message}`); + return null; + } + } + + /** + * column_labels에서 Entity 관계 정보 조회 + * 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기 + */ + async getEntityRelation( + detailTable: string, + masterTable: string + ): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> { + try { + logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`); + + const result = await queryOne( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [detailTable, masterTable] + ); + + if (!result) { + logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`); + return null; + } + + logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`); + + return { + detailFkColumn: result.column_name, + masterKeyColumn: result.reference_column, + }; + } catch (error: any) { + logger.error(`Entity 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 테이블의 컬럼 라벨 정보 조회 + */ + async getColumnLabels(tableName: string): Promise> { + try { + const result = await query( + `SELECT column_name, column_label + FROM column_labels + WHERE table_name = $1`, + [tableName] + ); + + const labelMap = new Map(); + for (const row of result) { + labelMap.set(row.column_name, row.column_label || row.column_name); + } + + return labelMap; + } catch (error: any) { + logger.error(`컬럼 라벨 조회 실패: ${error.message}`); + return new Map(); + } + } + + /** + * 마스터-디테일 관계 정보 조합 + */ + async getMasterDetailRelation( + screenId: number + ): Promise { + try { + // 1. 분할 패널 설정 조회 + const splitPanel = await this.getSplitPanelConfig(screenId); + if (!splitPanel) { + return null; + } + + const masterTable = splitPanel.leftPanel.tableName; + const detailTable = splitPanel.rightPanel.tableName; + + if (!masterTable || !detailTable) { + logger.warn("마스터 또는 디테일 테이블명 없음"); + return null; + } + + // 2. 분할 패널의 relation 정보가 있으면 우선 사용 + let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; + let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; + + // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회 + if (!masterKeyColumn || !detailFkColumn) { + const entityRelation = await this.getEntityRelation(detailTable, masterTable); + if (entityRelation) { + masterKeyColumn = entityRelation.masterKeyColumn; + detailFkColumn = entityRelation.detailFkColumn; + } + } + + if (!masterKeyColumn || !detailFkColumn) { + logger.warn("조인 키 정보를 찾을 수 없음"); + return null; + } + + // 4. 컬럼 라벨 정보 조회 + const masterLabels = await this.getColumnLabels(masterTable); + const detailLabels = await this.getColumnLabels(detailTable); + + // 5. 마스터 컬럼 정보 구성 + const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({ + name: col.name, + label: masterLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: true, + })); + + // 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외) + const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns + .filter(col => col.name !== detailFkColumn) // FK 컬럼 제외 + .map(col => ({ + name: col.name, + label: detailLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: false, + })); + + logger.info(`마스터-디테일 관계 구성 완료:`, { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumnCount: masterColumns.length, + detailColumnCount: detailColumns.length, + }); + + return { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumns, + detailColumns, + }; + } catch (error: any) { + logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용) + */ + async getJoinedData( + relation: MasterDetailRelation, + companyCode: string, + filters?: Record + ): Promise { + try { + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 조인 컬럼과 일반 컬럼 분리 + // 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name) + const entityJoins: Array<{ + refTable: string; + refColumn: string; + sourceColumn: string; + alias: string; + displayColumn: string; + }> = []; + + // SELECT 절 구성 + const selectParts: string[] = []; + let aliasIndex = 0; + + // 마스터 컬럼 처리 + for (const col of masterColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(masterTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + // FK를 못 찾으면 NULL로 처리 + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`m."${col.name}"`); + } + } + + // 디테일 컬럼 처리 + for (const col of detailColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(detailTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`d."${col.name}"`); + } + } + + const selectClause = selectParts.join(", "); + + // 엔티티 조인 절 구성 + const entityJoinClauses = entityJoins.map(ej => + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + ).join("\n "); + + // WHERE 절 구성 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (최고 관리자 제외) + if (companyCode && companyCode !== "*") { + whereConditions.push(`m.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // 추가 필터 적용 + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null && value !== "") { + // 조인 컬럼인지 확인 + if (key.includes(".")) continue; + // 마스터 테이블 컬럼인지 확인 + const isMasterCol = masterColumns.some(c => c.name === key); + const tableAlias = isMasterCol ? "m" : "d"; + whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`); + params.push(value); + paramIndex++; + } + } + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // JOIN 쿼리 실행 + const sql = ` + SELECT ${selectClause} + FROM "${masterTable}" m + LEFT JOIN "${detailTable}" d + ON m."${masterKeyColumn}" = d."${detailFkColumn}" + AND m.company_code = d.company_code + ${entityJoinClauses} + ${whereClause} + ORDER BY m."${masterKeyColumn}", d.id + `; + + logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); + + const data = await query(sql, params); + + // 헤더 및 컬럼 정보 구성 + const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)]; + const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)]; + + logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`); + + return { + headers, + columns, + data, + masterColumns: masterColumns.map(c => c.name), + detailColumns: detailColumns.map(c => c.name), + joinKey: masterKeyColumn, + }; + } catch (error: any) { + logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기 + */ + private async findForeignKeyColumn( + sourceTable: string, + referenceTable: string + ): Promise<{ sourceColumn: string; referenceColumn: string } | null> { + try { + const result = await query<{ column_name: string; reference_column: string }>( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND reference_table = $2 + AND input_type = 'entity' + LIMIT 1`, + [sourceTable, referenceTable] + ); + + if (result.length > 0) { + return { + sourceColumn: result[0].column_name, + referenceColumn: result[0].reference_column, + }; + } + return null; + } catch (error) { + logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error); + return null; + } + } + + /** + * 마스터-디테일 데이터 업로드 (엑셀 업로드용) + * + * 처리 로직: + * 1. 엑셀 데이터를 마스터 키로 그룹화 + * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT + * 3. 해당 마스터 키의 기존 디테일 삭제 + * 4. 새 디테일 데이터 INSERT + */ + async uploadJoinedData( + relation: MasterDetailRelation, + data: Record[], + companyCode: string, + userId?: string + ): Promise { + const result: ExcelUploadResult = { + success: false, + masterInserted: 0, + masterUpdated: 0, + detailInserted: 0, + detailDeleted: 0, + errors: [], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 1. 데이터를 마스터 키로 그룹화 + const groupedData = new Map[]>(); + + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); + + // 2. 각 그룹 처리 + for (const [masterKey, rows] of groupedData.entries()) { + try { + // 2a. 마스터 데이터 추출 (첫 번째 행에서) + const masterData: Record = {}; + for (const col of masterColumns) { + if (rows[0][col.name] !== undefined) { + masterData[col.name] = rows[0][col.name]; + } + } + + // 회사 코드, 작성자 추가 + masterData.company_code = companyCode; + if (userId) { + masterData.writer = userId; + } + + // 2b. 마스터 UPSERT + const existingMaster = await client.query( + `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + // UPDATE + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}, updated_date = NOW() + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // INSERT + const insertCols = Object.keys(masterData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.masterInserted++; + } + + // 2c. 기존 디테일 삭제 + const deleteResult = await client.query( + `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + result.detailDeleted += deleteResult.rowCount || 0; + + // 2d. 새 디테일 INSERT + for (const row of rows) { + const detailData: Record = {}; + + // FK 컬럼 추가 + detailData[detailFkColumn] = masterKey; + detailData.company_code = companyCode; + if (userId) { + detailData.writer = userId; + } + + // 디테일 컬럼 데이터 추출 + for (const col of detailColumns) { + if (row[col.name] !== undefined) { + detailData[col.name] = row[col.name]; + } + } + + const insertCols = Object.keys(detailData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => detailData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.detailInserted++; + } + } catch (error: any) { + result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); + logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0; + + logger.info(`마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + detailDeleted: result.detailDeleted, + errors: result.errors.length, + }); + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 마스터-디테일 간단 모드 업로드 + * + * 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함 + * 채번 규칙을 통해 마스터 키 자동 생성 + * + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성) + * @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional) + */ + async uploadSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId: string | undefined, + companyCode: string, + userId: string, + afterUploadFlowId?: string, + afterUploadFlows?: Array<{ flowId: string; order: number }> + ): Promise<{ + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + }> { + const result: { + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + } = { + success: false, + masterInserted: 0, + detailInserted: 0, + generatedKey: "", + errors: [] as string[], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. 마스터-디테일 관계 정보 조회 + const relation = await this.getMasterDetailRelation(screenId); + if (!relation) { + throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다."); + } + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation; + + // 2. 채번 처리 + let generatedKey: string; + + if (numberingRuleId) { + // 채번 규칙으로 키 생성 + generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode); + } else { + // 채번 규칙 없으면 마스터 필드에서 키 값 사용 + generatedKey = masterFieldValues[masterKeyColumn]; + if (!generatedKey) { + throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`); + } + } + + result.generatedKey = generatedKey; + logger.info(`채번 결과: ${generatedKey}`); + + // 3. 마스터 레코드 생성 + const masterData: Record = { + ...masterFieldValues, + [masterKeyColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 마스터 컬럼명 목록 구성 + const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined); + const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`); + const masterValues = masterCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${masterPlaceholders.join(", ")}, NOW())`, + masterValues + ); + result.masterInserted = 1; + logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`); + + // 4. 디테일 레코드들 생성 + for (const row of detailData) { + try { + const detailRowData: Record = { + ...row, + [detailFkColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 빈 값 필터링 및 id 제외 + const detailCols = Object.keys(detailRowData).filter(k => + k !== "id" && + detailRowData[k] !== undefined && + detailRowData[k] !== null && + detailRowData[k] !== "" + ); + const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`); + const detailValues = detailCols.map(k => detailRowData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${detailPlaceholders.join(", ")}, NOW())`, + detailValues + ); + result.detailInserted++; + } catch (error: any) { + result.errors.push(`디테일 행 처리 실패: ${error.message}`); + logger.error(`디테일 행 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.detailInserted > 0; + + logger.info(`마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + // 업로드 후 제어 실행 (단일 또는 다중) + const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0 + ? afterUploadFlows // 다중 제어 + : afterUploadFlowId + ? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성) + : []; + + if (flowsToExecute.length > 0 && result.success) { + try { + const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); + + // 마스터 데이터를 제어에 전달 + const masterData = { + ...masterFieldValues, + [relation!.masterKeyColumn]: result.generatedKey, + company_code: companyCode, + }; + + const controlResults: any[] = []; + + // 순서대로 제어 실행 + for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) { + logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`); + + const controlResult = await NodeFlowExecutionService.executeFlow( + parseInt(flow.flowId), + { + sourceData: [masterData], + dataSourceType: "formData", + buttonId: "excel-upload-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: masterData, + } + ); + + controlResults.push({ + flowId: flow.flowId, + order: flow.order, + success: controlResult.success, + message: controlResult.message, + executedNodes: controlResult.nodes?.length || 0, + }); + } + + result.controlResult = { + success: controlResults.every(r => r.success), + executedFlows: controlResults.length, + results: controlResults, + }; + + logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult); + } catch (controlError: any) { + logger.error(`업로드 후 제어 실행 실패:`, controlError); + result.controlResult = { + success: false, + message: `제어 실행 실패: ${controlError.message}`, + }; + } + } + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용) + */ + private async generateNumberWithRule( + client: any, + ruleId: string, + companyCode: string + ): Promise { + try { + // 기존 numberingRuleService를 사용하여 코드 할당 + const { numberingRuleService } = await import("./numberingRuleService"); + const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + + logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`); + + return generatedCode; + } catch (error: any) { + logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`); + throw error; + } + } +} + +export const masterDetailExcelService = new MasterDetailExcelService(); + diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 075a8229..a163f30c 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -2201,15 +2201,20 @@ export class MenuCopyService { "system", ]); - await client.query( + const result = await client.query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by - ) VALUES ${assignmentValues}`, + ) VALUES ${assignmentValues} + ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`, assignmentParams ); - } - logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); + logger.info( + `✅ 화면-메뉴 할당 완료: ${result.rowCount}개 삽입 (${validAssignments.length - (result.rowCount || 0)}개 중복 무시)` + ); + } else { + logger.info(`📭 화면-메뉴 할당할 항목 없음`); + } } /** diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 6f481198..bfd628ce 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -969,21 +969,56 @@ export class NodeFlowExecutionService { const insertedData = { ...data }; console.log("🗺️ 필드 매핑 처리 중..."); - fieldMappings.forEach((mapping: any) => { + + // 🔥 채번 규칙 서비스 동적 import + const { numberingRuleService } = await import("./numberingRuleService"); + + for (const mapping of fieldMappings) { fields.push(mapping.targetField); - const value = - mapping.staticValue !== undefined - ? mapping.staticValue - : data[mapping.sourceField]; - - console.log( - ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` - ); + let value: any; + + // 🔥 값 생성 유형에 따른 처리 + const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source"); + + if (valueType === "autoGenerate" && mapping.numberingRuleId) { + // 자동 생성 (채번 규칙) + const companyCode = context.buttonContext?.companyCode || "*"; + try { + value = await numberingRuleService.allocateCode( + mapping.numberingRuleId, + companyCode + ); + console.log( + ` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})` + ); + } catch (error: any) { + logger.error(`채번 규칙 적용 실패: ${error.message}`); + console.error( + ` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}` + ); + throw new Error( + `채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}` + ); + } + } else if (valueType === "static" || mapping.staticValue !== undefined) { + // 고정값 + value = mapping.staticValue; + console.log( + ` 📌 고정값: ${mapping.targetField} = ${value}` + ); + } else { + // 소스 필드 + value = data[mapping.sourceField]; + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); + } + values.push(value); // 🔥 삽입된 값을 데이터에 반영 insertedData[mapping.targetField] = value; - }); + } // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) const hasWriterMapping = fieldMappings.some( @@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService { } }); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, paramIndex ); @@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService { return deletedDataArray; } - // 🆕 context-data 모드: 개별 삭제 (PK 자동 추가) + // 🆕 context-data 모드: 개별 삭제 console.log("🎯 context-data 모드: 개별 삭제 시작"); for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, 1 ); @@ -2282,6 +2333,7 @@ export class NodeFlowExecutionService { UPDATE ${targetTable} SET ${setClauses.join(", ")} WHERE ${updateWhereConditions} + RETURNING * `; logger.info(`🔄 UPDATE 실행:`, { @@ -2292,8 +2344,14 @@ export class NodeFlowExecutionService { values: updateValues, }); - await txClient.query(updateSql, updateValues); + const updateResult = await txClient.query(updateSql, updateValues); updatedCount++; + + // 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능) + if (updateResult.rows && updateResult.rows[0]) { + Object.assign(data, updateResult.rows[0]); + logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`); + } } else { // 3-B. 없으면 INSERT const columns: string[] = []; @@ -2340,6 +2398,7 @@ export class NodeFlowExecutionService { const insertSql = ` INSERT INTO ${targetTable} (${columns.join(", ")}) VALUES (${placeholders}) + RETURNING * `; logger.info(`➕ INSERT 실행:`, { @@ -2348,8 +2407,14 @@ export class NodeFlowExecutionService { conflictKeyValues, }); - await txClient.query(insertSql, values); + const insertResult = await txClient.query(insertSql, values); insertedCount++; + + // 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능) + if (insertResult.rows && insertResult.rows[0]) { + Object.assign(data, insertResult.rows[0]); + logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`); + } } } @@ -2357,11 +2422,10 @@ export class NodeFlowExecutionService { `✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` ); - return { - insertedCount, - updatedCount, - totalCount: insertedCount + updatedCount, - }; + // 🔥 다음 노드에 전달할 데이터 반환 + // dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음 + // 카운트 정보도 함께 반환하여 기존 호환성 유지 + return dataArray; }; // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 @@ -2707,28 +2771,48 @@ export class NodeFlowExecutionService { const trueData: any[] = []; const falseData: any[] = []; - inputData.forEach((item: any) => { - const results = conditions.map((condition: any) => { + // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) + for (const item of inputData) { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = item[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = item[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.buttonContext?.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = item[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2740,7 +2824,7 @@ export class NodeFlowExecutionService { } else { falseData.push(item); } - }); + } logger.info( `🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)` @@ -2755,27 +2839,46 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 - const results = conditions.map((condition: any) => { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = inputData[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = inputData[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.buttonContext?.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = inputData[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2784,7 +2887,7 @@ export class NodeFlowExecutionService { logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`); - // ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 + // 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 // 조건 결과를 저장하고, 원본 데이터는 항상 반환 // 다음 노드에서 sourceHandle을 기반으로 필터링됨 return { @@ -2795,6 +2898,69 @@ export class NodeFlowExecutionService { }; } + /** + * EXISTS_IN / NOT_EXISTS_IN 조건 평가 + * 다른 테이블에 값이 존재하는지 확인 + */ + private static async evaluateExistsCondition( + fieldValue: any, + operator: string, + lookupTable: string, + lookupField: string, + companyCode?: string + ): Promise { + if (!lookupTable || !lookupField) { + logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다"); + return false; + } + + if (fieldValue === null || fieldValue === undefined || fieldValue === "") { + logger.info( + `⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)` + ); + // 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환 + // 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨 + return false; + } + + try { + // 멀티테넌시: company_code 필터 적용 여부 확인 + // company_mng 테이블은 제외 + const hasCompanyCode = lookupTable !== "company_mng" && companyCode; + + let sql: string; + let params: any[]; + + if (hasCompanyCode) { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`; + params = [fieldValue, companyCode]; + } else { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`; + params = [fieldValue]; + } + + logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`); + + const result = await query(sql, params); + const existsInTable = result[0]?.exists_result === true; + + logger.info( + `🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}` + ); + + // EXISTS_IN: 존재하면 true + // NOT_EXISTS_IN: 존재하지 않으면 true + if (operator === "EXISTS_IN") { + return existsInTable; + } else { + return !existsInTable; + } + } catch (error: any) { + logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`); + return false; + } + } + /** * WHERE 절 생성 */ diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 1638a417..edeb55b2 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1398,6 +1398,220 @@ class TableCategoryValueService { throw error; } } + + /** + * 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용) + * + * 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @returns { [columnName]: { [label]: code } } 형태의 매핑 객체 + */ + async getCategoryLabelToCodeMapping( + tableName: string, + companyCode: string + ): Promise>> { + try { + logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode }); + + const pool = getPool(); + + // 1. 해당 테이블의 카테고리 타입 컬럼 조회 + const categoryColumnsQuery = ` + SELECT column_name + FROM table_type_columns + WHERE table_name = $1 + AND input_type = 'category' + `; + const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]); + + if (categoryColumnsResult.rows.length === 0) { + logger.info("카테고리 타입 컬럼 없음", { tableName }); + return {}; + } + + const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name); + logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns }); + + // 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회 + const result: Record> = {}; + + for (const columnName of categoryColumns) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + params = [tableName, columnName]; + } else { + // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + AND (company_code = $3 OR company_code = '*') + `; + params = [tableName, columnName, companyCode]; + } + + const valuesResult = await pool.query(query, params); + + // { [label]: code } 형태로 변환 + const labelToCodeMap: Record = {}; + for (const row of valuesResult.rows) { + // 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑 + labelToCodeMap[row.value_label] = row.value_code; + // 소문자 키도 추가 (대소문자 무시 검색용) + labelToCodeMap[row.value_label.toLowerCase()] = row.value_code; + } + + if (Object.keys(labelToCodeMap).length > 0) { + result[columnName] = labelToCodeMap; + logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`); + } + } + + logger.info(`카테고리 라벨→코드 매핑 조회 완료`, { + tableName, + columnCount: Object.keys(result).length + }); + + return result; + } catch (error: any) { + logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 데이터의 카테고리 라벨 값을 코드 값으로 변환 + * + * 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @param data - 변환할 데이터 객체 + * @returns 라벨이 코드로 변환된 데이터 객체 + */ + async convertCategoryLabelsToCodesForData( + tableName: string, + companyCode: string, + data: Record + ): Promise<{ convertedData: Record; conversions: Array<{ column: string; label: string; code: string }> }> { + try { + // 라벨→코드 매핑 조회 + const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode); + + if (Object.keys(labelToCodeMapping).length === 0) { + // 카테고리 컬럼 없음 + return { convertedData: data, conversions: [] }; + } + + const convertedData = { ...data }; + const conversions: Array<{ column: string; label: string; code: string }> = []; + + for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) { + const value = data[columnName]; + + if (value !== undefined && value !== null && value !== "") { + const stringValue = String(value).trim(); + + // 다중 값 확인 (쉼표로 구분된 경우) + if (stringValue.includes(",")) { + // 다중 카테고리 값 처리 + const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== ""); + const convertedCodes: string[] = []; + let allConverted = true; + + for (const label of labels) { + // 정확한 라벨 매칭 시도 + let matchedCode = labelCodeMap[label]; + + // 대소문자 무시 매칭 + if (!matchedCode) { + matchedCode = labelCodeMap[label.toLowerCase()]; + } + + if (matchedCode) { + convertedCodes.push(matchedCode); + conversions.push({ + column: columnName, + label: label, + code: matchedCode, + }); + logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`); + } else { + // 이미 코드값인지 확인 + const isAlreadyCode = Object.values(labelCodeMap).includes(label); + if (isAlreadyCode) { + // 이미 코드값이면 그대로 사용 + convertedCodes.push(label); + } else { + // 라벨도 코드도 아니면 원래 값 유지 + convertedCodes.push(label); + allConverted = false; + logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`); + } + } + } + + // 변환된 코드들을 쉼표로 합쳐서 저장 + convertedData[columnName] = convertedCodes.join(","); + logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`); + } else { + // 단일 값 처리 + // 정확한 라벨 매칭 시도 + let matchedCode = labelCodeMap[stringValue]; + + // 대소문자 무시 매칭 + if (!matchedCode) { + matchedCode = labelCodeMap[stringValue.toLowerCase()]; + } + + if (matchedCode) { + // 라벨 값을 코드 값으로 변환 + convertedData[columnName] = matchedCode; + conversions.push({ + column: columnName, + label: stringValue, + code: matchedCode, + }); + logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`); + } else { + // 이미 코드값인지 확인 (역방향 확인) + const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue); + if (!isAlreadyCode) { + logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`); + } + // 변환 없이 원래 값 유지 + } + } + } + } + + logger.info(`카테고리 라벨→코드 변환 완료`, { + tableName, + conversionCount: conversions.length, + conversions, + }); + + return { convertedData, conversions }; + } catch (error: any) { + logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error }); + // 실패 시 원본 데이터 반환 + return { convertedData: data, conversions: [] }; + } + } } export default new TableCategoryValueService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index b714b186..2e67040a 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1306,6 +1306,41 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원) + // 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함 + if (Array.isArray(value) && value.length > 0) { + // 배열의 각 값에 대해 OR 조건으로 검색 + // 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로 + // 각 값을 LIKE 또는 = 조건으로 처리 + const conditions: string[] = []; + const values: any[] = []; + + value.forEach((v: any, idx: number) => { + const safeValue = String(v).trim(); + // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 + // 예: "2,3" 컬럼에서 "2"를 찾으려면: + // - 정확히 "2" + // - "2," 로 시작 + // - ",2" 로 끝남 + // - ",2," 중간에 포함 + const paramBase = paramIndex + (idx * 4); + conditions.push(`( + ${columnName}::text = $${paramBase} OR + ${columnName}::text LIKE $${paramBase + 1} OR + ${columnName}::text LIKE $${paramBase + 2} OR + ${columnName}::text LIKE $${paramBase + 3} + )`); + values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`); + }); + + logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`); + return { + whereClause: `(${conditions.join(" OR ")})`, + values, + paramCount: values.length, + }; + } + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo( @@ -2261,11 +2296,12 @@ export class TableManagementService { /** * 테이블에 데이터 추가 + * @returns 무시된 컬럼 정보 (디버깅용) */ async addTableData( tableName: string, data: Record - ): Promise { + ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); @@ -2296,10 +2332,41 @@ export class TableManagementService { logger.info(`created_date 자동 추가: ${data.created_date}`); } - // 컬럼명과 값을 분리하고 타입에 맞게 변환 - const columns = Object.keys(data); - const values = Object.values(data).map((value, index) => { - const columnName = columns[index]; + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) + const skippedColumns: string[] = []; + const existingColumns = Object.keys(data).filter((col) => { + const exists = columnTypeMap.has(col); + if (!exists) { + skippedColumns.push(col); + } + return exists; + }); + + // 무시된 컬럼이 있으면 경고 로그 출력 + if (skippedColumns.length > 0) { + logger.warn( + `⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}` + ); + logger.warn( + `⚠️ [${tableName}] 무시된 컬럼 상세:`, + skippedColumns.map((col) => ({ column: col, value: data[col] })) + ); + } + + if (existingColumns.length === 0) { + throw new Error( + `저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}` + ); + } + + logger.info( + `✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}` + ); + + // 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만) + const columns = existingColumns; + const values = columns.map((columnName) => { + const value = data[columnName]; const dataType = columnTypeMap.get(columnName) || "text"; const convertedValue = this.convertValueForPostgreSQL(value, dataType); logger.info( @@ -2355,6 +2422,12 @@ export class TableManagementService { await query(insertQuery, values); logger.info(`테이블 데이터 추가 완료: ${tableName}`); + + // 무시된 컬럼과 저장된 컬럼 정보 반환 + return { + skippedColumns, + savedColumns: existingColumns, + }; } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); throw error; @@ -2409,11 +2482,19 @@ export class TableManagementService { } // SET 절 생성 (수정할 데이터) - 먼저 생성 + // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) const setConditions: string[] = []; const setValues: any[] = []; let paramIndex = 1; + const skippedColumns: string[] = []; Object.keys(updatedData).forEach((column) => { + // 테이블에 존재하지 않는 컬럼은 스킵 + if (!columnTypeMap.has(column)) { + skippedColumns.push(column); + return; + } + const dataType = columnTypeMap.get(column) || "text"; setConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` @@ -2424,6 +2505,10 @@ export class TableManagementService { paramIndex++; }); + if (skippedColumns.length > 0) { + logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`); + } + // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) let whereConditions: string[] = []; let whereValues: any[] = []; @@ -2626,6 +2711,12 @@ export class TableManagementService { filterColumn?: string; filterValue?: any; }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + deduplication?: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }; // 🆕 중복 제거 설정 } ): Promise { const startTime = Date.now(); @@ -2676,33 +2767,64 @@ export class TableManagementService { ); for (const additionalColumn of options.additionalJoinColumns) { - // 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기) - const baseJoinConfig = joinConfigs.find( + // 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기 + let baseJoinConfig = joinConfigs.find( (config) => config.sourceColumn === additionalColumn.sourceColumn ); + // 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때) + // 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응 + if (!baseJoinConfig && (additionalColumn as any).referenceTable) { + baseJoinConfig = joinConfigs.find( + (config) => config.referenceTable === (additionalColumn as any).referenceTable + ); + if (baseJoinConfig) { + logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`); + } + } + if (baseJoinConfig) { - // joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name) - // sourceColumn을 제거한 나머지 부분이 실제 컬럼명 - const sourceColumn = baseJoinConfig.sourceColumn; // dept_code - const joinAlias = additionalColumn.joinAlias; // dept_code_company_name - const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name + // joinAlias에서 실제 컬럼명 추출 + const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id) + const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name) + + // 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리 + // customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거) + // 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거) + let actualColumnName: string; + + // 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출 + const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id) + if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) { + // 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, ""); + } else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) { + // 실제 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, ""); + } else { + // 어느 것도 아니면 원본 사용 + actualColumnName = originalJoinAlias; + } + + // 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반) + const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`; logger.info(`🔍 조인 컬럼 상세 분석:`, { sourceColumn, - joinAlias, + frontendSourceColumn, + originalJoinAlias, + correctedJoinAlias, actualColumnName, - referenceTable: additionalColumn.sourceTable, + referenceTable: (additionalColumn as any).referenceTable, }); // 🚨 기본 Entity 조인과 중복되지 않도록 체크 const isBasicEntityJoin = - additionalColumn.joinAlias === - `${baseJoinConfig.sourceColumn}_name`; + correctedJoinAlias === `${sourceColumn}_name`; if (isBasicEntityJoin) { logger.info( - `⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀` + `⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀` ); continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } @@ -2710,14 +2832,14 @@ export class TableManagementService { // 추가 조인 컬럼 설정 생성 const additionalJoinConfig: EntityJoinConfig = { sourceTable: tableName, - sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code) + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) referenceTable: (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (dept_info) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name) + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name) + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) separator: " - ", // 기본 구분자 }; @@ -3684,6 +3806,15 @@ export class TableManagementService { const cacheableJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = []; + // 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요) + const companySpecificTables = [ + "supplier_mng", + "customer_mng", + "item_info", + "dept_info", + // 필요시 추가 + ]; + for (const config of joinConfigs) { // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 if (config.referenceTable === "table_column_category_values") { @@ -3692,6 +3823,13 @@ export class TableManagementService { continue; } + // 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시) + if (companySpecificTables.includes(config.referenceTable)) { + dbJoins.push(config); + console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`); + continue; + } + // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, @@ -3930,9 +4068,10 @@ export class TableManagementService { `컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}` ); - // table_type_columns에서 입력타입 정보 조회 (company_code 필터링) + // table_type_columns에서 입력타입 정보 조회 + // 회사별 설정 우선, 없으면 기본 설정(*) fallback const rawInputTypes = await query( - `SELECT + `SELECT DISTINCT ON (ttc.column_name) ttc.column_name as "columnName", COALESCE(cl.column_label, ttc.column_name) as "displayName", ttc.input_type as "inputType", @@ -3946,8 +4085,10 @@ export class TableManagementService { LEFT JOIN information_schema.columns ic ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name WHERE ttc.table_name = $1 - AND ttc.company_code = $2 - ORDER BY ttc.display_order, ttc.column_name`, + AND ttc.company_code IN ($2, '*') + ORDER BY ttc.column_name, + CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END, + ttc.display_order`, [tableName, companyCode] ); @@ -3961,17 +4102,20 @@ export class TableManagementService { const mappingTableExists = tableExistsResult[0]?.table_exists === true; // 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회 + // 회사별 설정 우선, 없으면 기본 설정(*) fallback let categoryMappings: Map = new Map(); if (mappingTableExists) { logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); const mappings = await query( - `SELECT + `SELECT DISTINCT ON (logical_column_name, menu_objid) logical_column_name as "columnName", menu_objid as "menuObjid" FROM category_column_mapping WHERE table_name = $1 - AND company_code = $2`, + AND company_code IN ($2, '*') + ORDER BY logical_column_name, menu_objid, + CASE WHEN company_code = $2 THEN 0 ELSE 1 END`, [tableName, companyCode] ); @@ -4574,4 +4718,101 @@ export class TableManagementService { return false; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다. + * + * @param leftTable 좌측 테이블명 + * @param rightTable 우측 테이블명 + * @returns 감지된 엔티티 관계 배열 + */ + async detectTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise> { + try { + logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`); + + const relations: Array<{ + leftColumn: string; + rightColumn: string; + direction: "left_to_right" | "right_to_left"; + inputType: string; + displayColumn?: string; + }> = []; + + // 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code + const rightToLeftRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [rightTable, leftTable] + ); + + for (const rel of rightToLeftRels) { + relations.push({ + leftColumn: rel.reference_column, + rightColumn: rel.column_name, + direction: "right_to_left", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + // 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: left_table의 item_id -> right_table(item_info)의 item_number + const leftToRightRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [leftTable, rightTable] + ); + + for (const rel of leftToRightRels) { + relations.push({ + leftColumn: rel.column_name, + rightColumn: rel.reference_column, + direction: "left_to_right", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); + relations.forEach((rel, idx) => { + logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`); + }); + + return relations; + } catch (error) { + logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error); + return []; + } + } } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index c9349b94..32757807 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 42900211..8bfe484e 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -360,3 +360,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index c392eece..8d8fb497 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/frontend/app/(main)/admin/batchmng/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx similarity index 100% rename from frontend/app/(main)/admin/batchmng/create/page.tsx rename to frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx diff --git a/frontend/app/(main)/admin/batchmng/edit/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx similarity index 100% rename from frontend/app/(main)/admin/batchmng/edit/[id]/page.tsx rename to frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx diff --git a/frontend/app/(main)/admin/batchmng/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/batchmng/page.tsx rename to frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx diff --git a/frontend/app/(main)/admin/external-call-configs/page.tsx b/frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/external-call-configs/page.tsx rename to frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/automaticMng/exconList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/external-connections/page.tsx rename to frontend/app/(main)/admin/automaticMng/exconList/page.tsx diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx similarity index 100% rename from frontend/app/(main)/admin/flow-management/[id]/page.tsx rename to frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/flow-management/page.tsx rename to frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx diff --git a/frontend/app/(main)/admin/mail/accounts/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/accounts/page.tsx similarity index 100% rename from frontend/app/(main)/admin/mail/accounts/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/accounts/page.tsx diff --git a/frontend/app/(main)/admin/mail/bulk-send/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/bulk-send/page.tsx similarity index 100% rename from frontend/app/(main)/admin/mail/bulk-send/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/bulk-send/page.tsx diff --git a/frontend/app/(main)/admin/mail/dashboard/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/mail/dashboard/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx diff --git a/frontend/app/(main)/admin/mail/drafts/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx similarity index 99% rename from frontend/app/(main)/admin/mail/drafts/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx index c96129c1..e098352f 100644 --- a/frontend/app/(main)/admin/mail/drafts/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx @@ -51,7 +51,7 @@ export default function DraftsPage() { content: draft.htmlContent, accountId: draft.accountId, }); - router.push(`/admin/mail/send?${params.toString()}`); + router.push(`/admin/automaticMng/mail/send?${params.toString()}`); }; const handleDelete = async (id: string) => { diff --git a/frontend/app/(main)/admin/mail/receive/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/receive/page.tsx similarity index 100% rename from frontend/app/(main)/admin/mail/receive/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/receive/page.tsx diff --git a/frontend/app/(main)/admin/mail/send/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/send/page.tsx similarity index 99% rename from frontend/app/(main)/admin/mail/send/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/send/page.tsx index 56922043..cdd8feae 100644 --- a/frontend/app/(main)/admin/mail/send/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/mail/send/page.tsx @@ -1056,7 +1056,7 @@ ${data.originalBody}`; - diff --git a/frontend/app/(main)/admin/mail/templates/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/templates/page.tsx similarity index 100% rename from frontend/app/(main)/admin/mail/templates/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/templates/page.tsx diff --git a/frontend/app/(main)/admin/mail/trash/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/trash/page.tsx similarity index 100% rename from frontend/app/(main)/admin/mail/trash/page.tsx rename to frontend/app/(main)/admin/automaticMng/mail/trash/page.tsx diff --git a/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx deleted file mode 100644 index 7854e6ee..00000000 --- a/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { useParams } from "next/navigation"; -import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement"; - -export default function DepartmentManagementPage() { - const params = useParams(); - const companyCode = params.companyCode as string; - - return ; -} - diff --git a/frontend/app/(main)/admin/company/page.tsx b/frontend/app/(main)/admin/company/page.tsx deleted file mode 100644 index c24afc7a..00000000 --- a/frontend/app/(main)/admin/company/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { CompanyManagement } from "@/components/admin/CompanyManagement"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; - -/** - * 회사 관리 페이지 - */ -export default function CompanyPage() { - return ( -
-
- {/* 페이지 헤더 */} -
-

회사 관리

-

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

-
- - {/* 메인 컨텐츠 */} - -
- - {/* Scroll to Top 버튼 */} - -
- ); -} diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx deleted file mode 100644 index 613ab16b..00000000 --- a/frontend/app/(main)/admin/dashboard/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/dashboard/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/app/(main)/admin/dashboard/edit/[id]/page.tsx b/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx deleted file mode 100644 index 92220b6c..00000000 --- a/frontend/app/(main)/admin/dashboard/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/dashboard/new/page.tsx b/frontend/app/(main)/admin/dashboard/new/page.tsx deleted file mode 100644 index 56d28f46..00000000 --- a/frontend/app/(main)/admin/dashboard/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/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx deleted file mode 100644 index 7d09bafc..00000000 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; - -/** - * 대시보드 관리 페이지 - * - 클라이언트 컴포넌트를 렌더링하는 래퍼 - * - 초기 로딩부터 CSR로 처리 - */ -export default function DashboardListPage() { - return ( -
-
- {/* 페이지 헤더 */} -
-

대시보드 관리

-

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

-
- - {/* 클라이언트 컴포넌트 */} - -
-
- ); -} diff --git a/frontend/app/(main)/admin/i18n/page.tsx b/frontend/app/(main)/admin/i18n/page.tsx deleted file mode 100644 index 48655de7..00000000 --- a/frontend/app/(main)/admin/i18n/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import MultiLang from "@/components/admin/MultiLang"; - -export default function I18nPage() { - return ( -
-
- -
-
- ); -} - 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 f8d5d8d6..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 (
+ {/* 주요 관리 기능 */}
@@ -168,7 +169,7 @@ export default function AdminPage() {
- +
@@ -182,7 +183,7 @@ export default function AdminPage() {
- +
diff --git a/frontend/app/(main)/admin/roles/[id]/page.tsx b/frontend/app/(main)/admin/roles/[id]/page.tsx deleted file mode 100644 index a1579bf2..00000000 --- a/frontend/app/(main)/admin/roles/[id]/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { use } from "react"; -import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; - -/** - * 권한 그룹 상세 페이지 - * URL: /admin/roles/[id] - * - * 기능: - * - 권한 그룹 멤버 관리 (Dual List Box) - * - 메뉴 권한 설정 (CRUD 체크박스) - */ -export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) { - // Next.js 15: params는 Promise이므로 React.use()로 unwrap - const { id } = use(params); - - return ( -
-
- {/* 메인 컨텐츠 */} - -
- - {/* Scroll to Top 버튼 (모바일/태블릿 전용) */} - -
- ); -} diff --git a/frontend/app/(main)/admin/roles/page.tsx b/frontend/app/(main)/admin/roles/page.tsx deleted file mode 100644 index 2b973ad5..00000000 --- a/frontend/app/(main)/admin/roles/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { RoleManagement } from "@/components/admin/RoleManagement"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; - -/** - * 권한 그룹 관리 페이지 - * URL: /admin/roles - * - * shadcn/ui 스타일 가이드 적용 - * - * 기능: - * - 회사별 권한 그룹 목록 조회 - * - 권한 그룹 생성/수정/삭제 - * - 멤버 관리 (Dual List Box) - * - 메뉴 권한 설정 (CRUD 권한) - */ -export default function RolesPage() { - return ( -
-
- {/* 페이지 헤더 */} -
-

권한 그룹 관리

-

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

-
- - {/* 메인 컨텐츠 */} - -
- - {/* Scroll to Top 버튼 (모바일/태블릿 전용) */} - -
- ); -} - 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 08296fd1..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([]); @@ -643,7 +650,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D open={successModalOpen} onOpenChange={() => { setSuccessModalOpen(false); - router.push("/admin/dashboard"); + router.push("/admin/screenMng/dashboardList"); }} > @@ -660,7 +667,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D +
+ + {/* 대시보드 목록 */} + {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/report/designer/[reportId]/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx similarity index 96% rename from frontend/app/(main)/admin/report/designer/[reportId]/page.tsx rename to frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx index 03d5bcd9..55972a18 100644 --- a/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx @@ -37,7 +37,7 @@ export default function ReportDesignerPage() { description: "리포트를 찾을 수 없습니다.", variant: "destructive", }); - router.push("/admin/report"); + router.push("/admin/screenMng/reportList"); } } catch (error: any) { toast({ @@ -45,7 +45,7 @@ export default function ReportDesignerPage() { description: error.message || "리포트를 불러오는데 실패했습니다.", variant: "destructive", }); - router.push("/admin/report"); + router.push("/admin/screenMng/reportList"); } finally { setIsLoading(false); } diff --git a/frontend/app/(main)/admin/report/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/page.tsx similarity index 98% rename from frontend/app/(main)/admin/report/page.tsx rename to frontend/app/(main)/admin/screenMng/reportList/page.tsx index 37270683..4b3816be 100644 --- a/frontend/app/(main)/admin/report/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/page.tsx @@ -26,7 +26,7 @@ export default function ReportManagementPage() { const handleCreateNew = () => { // 새 리포트는 'new'라는 특수 ID로 디자이너 진입 - router.push("/admin/report/designer/new"); + router.push("/admin/screenMng/reportList/designer/new"); }; return ( diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx similarity index 99% rename from frontend/app/(main)/admin/screenMng/page.tsx rename to frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 3145d9d3..0327e122 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -127,3 +127,4 @@ export default function ScreenManagementPage() {
); } + diff --git a/frontend/app/(main)/admin/collection-management/page.tsx b/frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/collection-management/page.tsx rename to frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx diff --git a/frontend/app/(main)/admin/commonCode/page.tsx b/frontend/app/(main)/admin/systemMng/commonCodeList/page.tsx similarity index 100% rename from frontend/app/(main)/admin/commonCode/page.tsx rename to frontend/app/(main)/admin/systemMng/commonCodeList/page.tsx diff --git a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page.tsx similarity index 100% rename from frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx rename to frontend/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page.tsx diff --git a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx similarity index 92% rename from frontend/app/(main)/admin/dataflow/node-editor/page.tsx rename to frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx index 9e1cfab6..55435ab2 100644 --- a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx +++ b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx @@ -13,7 +13,7 @@ export default function NodeEditorPage() { useEffect(() => { // /admin/dataflow 메인 페이지로 리다이렉트 - router.replace("/admin/dataflow"); + router.replace("/admin/systemMng/dataflow"); }, [router]); return ( diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx similarity index 90% rename from frontend/app/(main)/admin/dataflow/page.tsx rename to frontend/app/(main)/admin/systemMng/dataflow/page.tsx index 87d937ec..d55a6cf1 100644 --- a/frontend/app/(main)/admin/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 ( -
+
{/* 페이지 헤더 */}

제어 관리

-

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

+

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

{/* 플로우 목록 */} diff --git a/frontend/components/admin/MultiLang.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx similarity index 65% rename from frontend/components/admin/MultiLang.tsx rename to frontend/app/(main)/admin/systemMng/i18nList/page.tsx index abdadcdb..3acce6fb 100644 --- a/frontend/components/admin/MultiLang.tsx +++ b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx @@ -11,8 +11,8 @@ 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 LangKeyModal from "@/components/admin/LangKeyModal"; +import LanguageModal from "@/components/admin/LanguageModal"; import { apiClient } from "@/lib/api/client"; interface Language { @@ -39,7 +39,7 @@ interface LangText { isActive: string; } -export default function MultiLangPage() { +export default function I18nPage() { const { user } = useAuth(); const [loading, setLoading] = useState(true); const [languages, setLanguages] = useState([]); @@ -64,20 +64,14 @@ export default function MultiLangPage() { // 회사 목록 조회 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); @@ -103,17 +97,14 @@ export default function MultiLangPage() { 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; @@ -146,16 +137,12 @@ export default function MultiLangPage() { // 선택된 키의 다국어 텍스트 조회 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); @@ -164,20 +151,10 @@ export default function MultiLangPage() { // 언어 키 선택 처리 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]; @@ -203,7 +180,6 @@ export default function MultiLangPage() { if (!selectedKey) return; try { - // 백엔드가 기대하는 형식으로 데이터 변환 const requestData = { texts: editingTexts.map((text) => ({ langCode: text.langCode, @@ -218,18 +194,16 @@ export default function MultiLangPage() { const data = response.data; if (data.success) { alert("저장되었습니다."); - // 저장 후 다시 조회 fetchLangTexts(selectedKey.keyId); } } catch (error) { - // console.error("텍스트 저장 실패:", error); alert("저장에 실패했습니다."); } }; // 언어 키 추가/수정 모달 열기 const handleAddKey = () => { - setEditingKey(null); // 새 키 추가는 null로 설정 + setEditingKey(null); setIsModalOpen(true); }; @@ -266,12 +240,11 @@ export default function MultiLangPage() { if (result.success) { alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다."); setIsLanguageModalOpen(false); - fetchLanguages(); // 언어 목록 새로고침 + fetchLanguages(); } else { alert(`오류: ${result.message}`); } } catch (error) { - // console.error("언어 저장 중 오류:", error); alert("언어 저장 중 오류가 발생했습니다."); } }; @@ -302,12 +275,11 @@ export default function MultiLangPage() { if (failedDeletes.length === 0) { alert("선택된 언어가 삭제되었습니다."); setSelectedLanguages(new Set()); - fetchLanguages(); // 언어 목록 새로고침 + fetchLanguages(); } else { alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`); } } catch (error) { - // console.error("언어 삭제 중 오류:", error); alert("언어 삭제 중 오류가 발생했습니다."); } }; @@ -358,10 +330,9 @@ export default function MultiLangPage() { if (data.success) { alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다."); - fetchLangKeys(); // 목록 새로고침 + fetchLangKeys(); setIsModalOpen(false); } else { - // 중복 체크 오류 메시지 처리 if (data.message && data.message.includes("이미 존재하는 언어키")) { alert(data.message); } else { @@ -369,7 +340,6 @@ export default function MultiLangPage() { } } } catch (error) { - // console.error("언어 키 저장 실패:", error); alert("언어 키 저장에 실패했습니다."); } }; @@ -397,7 +367,6 @@ export default function MultiLangPage() { alert("상태 변경 중 오류가 발생했습니다."); } } catch (error) { - // console.error("키 상태 토글 실패:", error); alert("키 상태 변경 중 오류가 발생했습니다."); } }; @@ -414,7 +383,6 @@ export default function MultiLangPage() { alert("언어 상태 변경 중 오류가 발생했습니다."); } } catch (error) { - // console.error("언어 상태 토글 실패:", error); alert("언어 상태 변경 중 오류가 발생했습니다."); } }; @@ -453,9 +421,8 @@ export default function MultiLangPage() { if (allSuccess) { alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`); setSelectedKeys(new Set()); - fetchLangKeys(); // 목록 새로고침 + fetchLangKeys(); - // 선택된 키가 삭제된 경우 편집 영역 닫기 if (selectedKey && selectedKeys.has(selectedKey.keyId)) { handleCancel(); } @@ -463,12 +430,11 @@ export default function MultiLangPage() { alert("일부 키 삭제에 실패했습니다."); } } catch (error) { - // console.error("선택된 키 삭제 실패:", error); alert("선택된 키 삭제에 실패했습니다."); } }; - // 개별 키 삭제 (기존 함수 유지) + // 개별 키 삭제 const handleDeleteKey = async (keyId: number) => { if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) { return; @@ -479,13 +445,12 @@ export default function MultiLangPage() { const data = response.data; if (data.success) { alert("언어 키가 영구적으로 삭제되었습니다."); - fetchLangKeys(); // 목록 새로고침 + fetchLangKeys(); if (selectedKey && selectedKey.keyId === keyId) { - handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기 + handleCancel(); } } } catch (error) { - // console.error("언어 키 삭제 실패:", error); alert("언어 키 삭제에 실패했습니다."); } }; @@ -506,8 +471,6 @@ export default function MultiLangPage() { initializeData(); }, []); - // 검색 관련 useEffect 제거 - 실시간 필터링만 사용 - const columns = [ { id: "select", @@ -552,7 +515,6 @@ export default function MultiLangPage() { {row.original.menuName} ), }, - { accessorKey: "langKey", header: "언어 키", @@ -567,7 +529,6 @@ export default function MultiLangPage() {
), }, - { accessorKey: "description", header: "설명", @@ -667,193 +628,198 @@ export default function MultiLangPage() { } 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" - /> -
- ); - })} -
- {/* 저장 버튼 - 고정 위치 */} -
- - -
-
- ) : ( -
-
-
언어 키를 선택하세요
-
좌측 목록에서 편집할 언어 키를 클릭하세요
-
-
- )} -
-
+
+
+
+ {/* 탭 네비게이션 */} +
+ +
- )} + + {/* 메인 콘텐츠 영역 */} +
+ {/* 언어 관리 탭 */} + {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} + /> +
- - {/* 언어 키 추가/수정 모달 */} - setIsModalOpen(false)} - onSave={handleSaveKey} - keyData={editingKey} - companies={companies} - /> - - {/* 언어 추가/수정 모달 */} - setIsLanguageModalOpen(false)} - onSave={handleSaveLanguage} - languageData={editingLanguage} - />
); } + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx similarity index 78% rename from frontend/app/(main)/admin/tableMng/page.tsx rename to frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 0b5ff573..4ba1e6c0 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { useMultiLang } from "@/hooks/useMultiLang"; @@ -90,6 +93,13 @@ export default function TableManagementPage() { // 🎯 Entity 조인 관련 상태 const [referenceTableColumns, setReferenceTableColumns] = useState>({}); + // 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리) + const [entityComboboxOpen, setEntityComboboxOpen] = useState>({}); + // DDL 기능 관련 상태 const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); @@ -1388,113 +1398,266 @@ export default function TableManagementPage() { {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> - {/* 참조 테이블 */} -
+ {/* 참조 테이블 - 검색 가능한 Combobox */} +
- + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {referenceTableOptions.map((option) => ( + { + handleDetailSettingsChange(column.columnName, "entity", option.value); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], table: false }, + })); + }} + className="text-xs" + > + +
+ {option.label} + {option.value !== "none" && ( + {option.value} + )} +
+
+ ))} +
+
+
+
+
- {/* 조인 컬럼 */} + {/* 조인 컬럼 - 검색 가능한 Combobox */} {column.referenceTable && column.referenceTable !== "none" && ( -
+
- + 로딩중... + + ) : column.referenceColumn && column.referenceColumn !== "none" ? ( + column.referenceColumn + ) : ( + "컬럼 선택..." + )} + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + { + handleDetailSettingsChange(column.columnName, "entity_reference_column", "none"); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + })); + }} + className="text-xs" + > + + -- 선택 안함 -- + + {referenceTableColumns[column.referenceTable]?.map((refCol) => ( + { + handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + })); + }} + className="text-xs" + > + +
+ {refCol.columnName} + {refCol.columnLabel && ( + {refCol.columnLabel} + )} +
+
+ ))} +
+
+
+
+
)} - {/* 표시 컬럼 */} + {/* 표시 컬럼 - 검색 가능한 Combobox */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && column.referenceColumn !== "none" && ( -
+
- + 로딩중... + + ) : column.displayColumn && column.displayColumn !== "none" ? ( + column.displayColumn + ) : ( + "컬럼 선택..." + )} + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + { + handleDetailSettingsChange(column.columnName, "entity_display_column", "none"); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], displayColumn: false }, + })); + }} + className="text-xs" + > + + -- 선택 안함 -- + + {referenceTableColumns[column.referenceTable]?.map((refCol) => ( + { + handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], displayColumn: false }, + })); + }} + className="text-xs" + > + +
+ {refCol.columnName} + {refCol.columnLabel && ( + {refCol.columnLabel} + )} +
+
+ ))} +
+
+
+
+
)} @@ -1505,8 +1668,8 @@ export default function TableManagementPage() { column.referenceColumn !== "none" && column.displayColumn && column.displayColumn !== "none" && ( -
- +
+ 설정 완료
)} diff --git a/frontend/app/(main)/admin/userAuth/page.tsx b/frontend/app/(main)/admin/userAuth/page.tsx deleted file mode 100644 index 322bba64..00000000 --- a/frontend/app/(main)/admin/userAuth/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { UserAuthManagement } from "@/components/admin/UserAuthManagement"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; - -/** - * 사용자 권한 관리 페이지 - * URL: /admin/userAuth - * - * 최고 관리자만 접근 가능 - * 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리 - */ -export default function UserAuthPage() { - return ( -
-
- {/* 페이지 헤더 */} -
-

사용자 권한 관리

-

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

-
- - {/* 메인 컨텐츠 */} - -
- - {/* Scroll to Top 버튼 (모바일/태블릿 전용) */} - -
- ); -} diff --git a/frontend/components/admin/department/DepartmentManagement.tsx b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx similarity index 89% rename from frontend/components/admin/department/DepartmentManagement.tsx rename to frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx index e82be525..a9cd747c 100644 --- a/frontend/components/admin/department/DepartmentManagement.tsx +++ b/frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx @@ -1,25 +1,23 @@ "use client"; import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; +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 "./DepartmentStructure"; -import { DepartmentMembers } from "./DepartmentMembers"; +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"; -interface DepartmentManagementProps { - companyCode: string; -} - /** - * 부서 관리 메인 컴포넌트 + * 부서 관리 메인 페이지 * 좌측: 부서 구조, 우측: 부서 인원 */ -export function DepartmentManagement({ companyCode }: DepartmentManagementProps) { +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(""); @@ -45,7 +43,7 @@ export function DepartmentManagement({ companyCode }: DepartmentManagementProps) }, [companyCode]); const handleBackToList = () => { - router.push("/admin/company"); + router.push("/admin/userMng/companyList"); }; return ( diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx new file mode 100644 index 00000000..a36cd9c3 --- /dev/null +++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useCompanyManagement } from "@/hooks/useCompanyManagement"; +import { CompanyToolbar } from "@/components/admin/CompanyToolbar"; +import { CompanyTable } from "@/components/admin/CompanyTable"; +import { CompanyFormModal } from "@/components/admin/CompanyFormModal"; +import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog"; +import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; + +/** + * 회사 관리 페이지 + * 모든 회사 관리 기능을 통합하여 제공 + */ +export default function CompanyPage() { + const { + // 데이터 + companies, + searchFilter, + isLoading, + error, + + // 디스크 사용량 관련 + diskUsageInfo, + isDiskUsageLoading, + loadDiskUsage, + + // 모달 상태 + modalState, + deleteState, + + // 검색 기능 + updateSearchFilter, + clearSearchFilter, + + // 모달 제어 + openCreateModal, + openEditModal, + closeModal, + updateFormData, + + // 삭제 다이얼로그 제어 + openDeleteDialog, + closeDeleteDialog, + + // CRUD 작업 + saveCompany, + deleteCompany, + + // 에러 처리 + clearError, + } = useCompanyManagement(); + + return ( +
+
+ {/* 페이지 헤더 */} +
+

회사 관리

+

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

+
+ + {/* 디스크 사용량 요약 */} + + + {/* 툴바 - 검색, 필터, 등록 버튼 */} + + + {/* 회사 목록 테이블 */} + + + {/* 회사 등록/수정 모달 */} + + + {/* 회사 삭제 확인 다이얼로그 */} + +
+ + {/* Scroll to Top 버튼 */} + +
+ ); +} diff --git a/frontend/app/(main)/admin/userMng/page.tsx b/frontend/app/(main)/admin/userMng/page.tsx deleted file mode 100644 index 428e8986..00000000 --- a/frontend/app/(main)/admin/userMng/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { UserManagement } from "@/components/admin/UserManagement"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; - -/** - * 사용자관리 페이지 - * URL: /admin/userMng - * - * shadcn/ui 스타일 가이드 적용 - */ -export default function UserMngPage() { - return ( -
-
- {/* 페이지 헤더 */} -
-

사용자 관리

-

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

-
- - {/* 메인 컨텐츠 */} - -
- - {/* Scroll to Top 버튼 (모바일/태블릿 전용) */} - -
- ); -} diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx similarity index 63% rename from frontend/components/admin/RoleDetailManagement.tsx rename to frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx index 27a6c07d..30552af4 100644 --- a/frontend/components/admin/RoleDetailManagement.tsx +++ b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx @@ -1,29 +1,28 @@ "use client"; import React, { useState, useCallback, useEffect } from "react"; +import { use } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react"; +import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react"; import { roleAPI, RoleGroup } from "@/lib/api/role"; import { 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 { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable"; import { useMenu } from "@/contexts/MenuContext"; - -interface RoleDetailManagementProps { - roleId: string; -} +import { ScrollToTop } from "@/components/common/ScrollToTop"; /** - * 권한 그룹 상세 관리 컴포넌트 + * 권한 그룹 상세 페이지 + * URL: /admin/userMng/rolesList/[id] * * 기능: - * - 권한 그룹 정보 표시 - * - 멤버 관리 (Dual List Box) + * - 권한 그룹 멤버 관리 (Dual List Box) * - 메뉴 권한 설정 (CRUD 체크박스) */ -export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { +export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) { + // Next.js 15: params는 Promise이므로 React.use()로 unwrap + const { id: roleId } = use(params); const { user: currentUser } = useAuth(); const router = useRouter(); const { refreshMenus } = useMenu(); @@ -236,7 +235,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {

오류 발생

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

-
@@ -244,102 +243,107 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { } return ( - <> - {/* 페이지 헤더 */} -
-
- -
-

{roleGroup.authName}

-

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

+
+
+ {/* 페이지 헤더 */} +
+
+ +
+

{roleGroup.authName}

+

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

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

멤버 관리

+

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

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

메뉴 권한 설정

+

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

+
+ +
+ + + + )}
- {/* 탭 네비게이션 */} -
- - -
- - {/* 탭 컨텐츠 */} -
- {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 new file mode 100644 index 00000000..eeac1dec --- /dev/null +++ b/frontend/app/(main)/admin/userMng/rolesList/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react"; +import { roleAPI, RoleGroup } from "@/lib/api/role"; +import { useAuth } from "@/hooks/useAuth"; +import { AlertCircle } from "lucide-react"; +import { RoleFormModal } from "@/components/admin/RoleFormModal"; +import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal"; +import { useRouter } from "next/navigation"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { companyAPI } from "@/lib/api/company"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; + +/** + * 권한 그룹 관리 페이지 + * URL: /admin/roles + * + * shadcn/ui 스타일 가이드 적용 + * + * 기능: + * - 회사별 권한 그룹 목록 조회 + * - 권한 그룹 생성/수정/삭제 + * - 멤버 관리 (Dual List Box) + * - 메뉴 권한 설정 (CRUD 권한) + * - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정) + */ +export default function RolesPage() { + const { user: currentUser } = useAuth(); + const router = useRouter(); + + // 회사 관리자 또는 최고 관리자 여부 + const isAdmin = + (currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") || + currentUser?.userType === "COMPANY_ADMIN"; + const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; + + // 상태 관리 + const [roleGroups, setRoleGroups] = useState([]); + 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 new file mode 100644 index 00000000..4ad69183 --- /dev/null +++ b/frontend/app/(main)/admin/userMng/userAuthList/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { UserAuthTable } from "@/components/admin/UserAuthTable"; +import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal"; +import { userAPI } from "@/lib/api/user"; +import { useAuth } from "@/hooks/useAuth"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; + +/** + * 사용자 권한 관리 페이지 + * URL: /admin/userAuth + * + * 최고 관리자만 접근 가능 + * 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리 + */ +export default function UserAuthPage() { + const { user: currentUser } = useAuth(); + + // 최고 관리자 여부 + const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; + + // 상태 관리 + const [users, setUsers] = useState([]); + 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}

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

사용자 관리

+

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

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

오류가 발생했습니다

+ +
+

{error}

+
+ )} + + {/* 사용자 목록 테이블 */} + + + {/* 페이지네이션 */} + {!isLoading && users.length > 0 && ( + + )} + + {/* 사용자 등록/수정 모달 */} + + + {/* 비밀번호 초기화 모달 */} + +
+ + {/* Scroll to Top 버튼 (모바일/태블릿 전용) */} + +
+ ); +} diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 16827791..44ca30b2 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -142,7 +142,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { {/* 편집 버튼 *\/}
➕ 새 대시보드 만들기 @@ -185,7 +185,7 @@ export default function DashboardListPage() {

{!searchTerm && ( ➕ 대시보드 만들기 @@ -251,7 +251,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) { 보기 편집 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/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index dffbd75b..9e92bf2b 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -104,7 +104,7 @@ function ScreenViewPage() { // 편집 모달 이벤트 리스너 등록 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); setEditModalConfig({ screenId: event.detail.screenId, diff --git a/frontend/app/(pop)/layout.tsx b/frontend/app/(pop)/layout.tsx new file mode 100644 index 00000000..1c41d1c0 --- /dev/null +++ b/frontend/app/(pop)/layout.tsx @@ -0,0 +1,10 @@ +import "@/app/globals.css"; + +export const metadata = { + title: "POP - 생산실적관리", + description: "생산 현장 실적 관리 시스템", +}; + +export default function PopLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/frontend/app/(pop)/pop/page.tsx b/frontend/app/(pop)/pop/page.tsx new file mode 100644 index 00000000..3cf5de33 --- /dev/null +++ b/frontend/app/(pop)/pop/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PopDashboard } from "@/components/pop/dashboard"; + +export default function PopPage() { + return ; +} diff --git a/frontend/app/(pop)/pop/work/page.tsx b/frontend/app/(pop)/pop/work/page.tsx new file mode 100644 index 00000000..15608959 --- /dev/null +++ b/frontend/app/(pop)/pop/work/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PopApp } from "@/components/pop"; + +export default function PopWorkPage() { + return ; +} + diff --git a/frontend/app/(pop)/work/page.tsx b/frontend/app/(pop)/work/page.tsx new file mode 100644 index 00000000..15608959 --- /dev/null +++ b/frontend/app/(pop)/work/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PopApp } from "@/components/pop"; + +export default function PopWorkPage() { + return ; +} + diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 06b7bd27..2fbbe7c5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -388,4 +388,226 @@ select { border-spacing: 0 !important; } +/* ===== POP (Production Operation Panel) Styles ===== */ + +/* POP 전용 다크 테마 변수 */ +.pop-dark { + /* 배경 색상 */ + --pop-bg-deepest: 8 12 21; + --pop-bg-deep: 10 15 28; + --pop-bg-primary: 13 19 35; + --pop-bg-secondary: 18 26 47; + --pop-bg-tertiary: 25 35 60; + --pop-bg-elevated: 32 45 75; + + /* 네온 강조색 */ + --pop-neon-cyan: 0 212 255; + --pop-neon-cyan-bright: 0 240 255; + --pop-neon-cyan-dim: 0 150 190; + --pop-neon-pink: 255 0 102; + --pop-neon-purple: 138 43 226; + + /* 상태 색상 */ + --pop-success: 0 255 136; + --pop-success-dim: 0 180 100; + --pop-warning: 255 170 0; + --pop-warning-dim: 200 130 0; + --pop-danger: 255 51 51; + --pop-danger-dim: 200 40 40; + + /* 텍스트 색상 */ + --pop-text-primary: 255 255 255; + --pop-text-secondary: 180 195 220; + --pop-text-muted: 100 120 150; + + /* 테두리 색상 */ + --pop-border: 40 55 85; + --pop-border-light: 55 75 110; +} + +/* POP 전용 라이트 테마 변수 */ +.pop-light { + --pop-bg-deepest: 245 247 250; + --pop-bg-deep: 240 243 248; + --pop-bg-primary: 250 251 253; + --pop-bg-secondary: 255 255 255; + --pop-bg-tertiary: 245 247 250; + --pop-bg-elevated: 235 238 245; + + --pop-neon-cyan: 0 122 204; + --pop-neon-cyan-bright: 0 140 230; + --pop-neon-cyan-dim: 0 100 170; + --pop-neon-pink: 220 38 127; + --pop-neon-purple: 118 38 200; + + --pop-success: 22 163 74; + --pop-success-dim: 21 128 61; + --pop-warning: 245 158 11; + --pop-warning-dim: 217 119 6; + --pop-danger: 220 38 38; + --pop-danger-dim: 185 28 28; + + --pop-text-primary: 15 23 42; + --pop-text-secondary: 71 85 105; + --pop-text-muted: 148 163 184; + + --pop-border: 226 232 240; + --pop-border-light: 203 213 225; +} + +/* POP 배경 그리드 패턴 */ +.pop-bg-pattern::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%); + pointer-events: none; + z-index: 0; +} + +.pop-light .pop-bg-pattern::before { + background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%); +} + +/* POP 글로우 효과 */ +.pop-glow-cyan { + box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3); +} + +.pop-glow-cyan-strong { + box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3); +} + +.pop-glow-success { + box-shadow: 0 0 15px rgba(0, 255, 136, 0.5); +} + +.pop-glow-warning { + box-shadow: 0 0 15px rgba(255, 170, 0, 0.5); +} + +.pop-glow-danger { + box-shadow: 0 0 15px rgba(255, 51, 51, 0.5); +} + +/* POP 펄스 글로우 애니메이션 */ +@keyframes pop-pulse-glow { + 0%, + 100% { + box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); + } + 50% { + box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4); + } +} + +.pop-animate-pulse-glow { + animation: pop-pulse-glow 2s ease-in-out infinite; +} + +/* POP 프로그레스 바 샤인 애니메이션 */ +@keyframes pop-progress-shine { + 0% { + opacity: 0; + transform: translateX(-20px); + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(20px); + } +} + +.pop-progress-shine::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 20px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3)); + animation: pop-progress-shine 1.5s ease-in-out infinite; +} + +/* POP 스크롤바 스타일 */ +.pop-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.pop-scrollbar::-webkit-scrollbar-track { + background: rgb(var(--pop-bg-secondary)); +} + +.pop-scrollbar::-webkit-scrollbar-thumb { + background: rgb(var(--pop-border-light)); + border-radius: 9999px; +} + +.pop-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgb(var(--pop-neon-cyan-dim)); +} + +/* POP 스크롤바 숨기기 */ +.pop-hide-scrollbar::-webkit-scrollbar { + display: none; +} + +.pop-hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* ===== Marching Ants Animation (Excel Copy Border) ===== */ +@keyframes marching-ants-h { + 0% { + background-position: 0 0; + } + 100% { + background-position: 16px 0; + } +} + +@keyframes marching-ants-v { + 0% { + background-position: 0 0; + } + 100% { + background-position: 0 16px; + } +} + +.animate-marching-ants-h { + background: repeating-linear-gradient( + 90deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 16px 2px; + animation: marching-ants-h 0.4s linear infinite; +} + +.animate-marching-ants-v { + background: repeating-linear-gradient( + 180deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 2px 16px; + animation: marching-ants-v 0.4s linear infinite; +} + /* ===== End of Global Styles ===== */ 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/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index b36a757b..9c253765 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -22,7 +22,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company // 부서 관리 페이지로 이동 const handleManageDepartments = (company: Company) => { - router.push(`/admin/company/${company.company_code}/departments`); + router.push(`/admin/userMng/companyList/${company.company_code}/departments`); }; // 디스크 사용량 포맷팅 함수 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/RoleManagement.tsx b/frontend/components/admin/RoleManagement.tsx deleted file mode 100644 index fe527fe4..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/roles/${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/dashboard/data-sources/DatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx index 8ca94e30..db0ff9d1 100644 --- a/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx @@ -91,7 +91,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) { +
fileInputRef.current?.click()} + className={cn( + "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors", + isDragOver + ? "border-primary bg-primary/5" + : file + ? "border-green-500 bg-green-50" + : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50" + )} + > + {file ? ( +
+ +
+

{file.name}

+

+ 클릭하여 다른 파일 선택 +

+
+
+ ) : ( + <> + +

+ {isDragOver + ? "파일을 놓으세요" + : "파일을 드래그하거나 클릭하여 선택"} +

+

+ 지원 형식: .xlsx, .xls, .csv +

+ + )} = ({ className="hidden" />
-

- 지원 형식: .xlsx, .xls, .csv -

- {sheetNames.length > 0 && ( -
- - -
- )} -
- )} - - {/* 2단계: 범위 지정 */} - {currentStep === 2 && ( -
- {/* 상단: 3개 드롭다운 가로 배치 */} -
- - - - - -
- - {/* 중간: 체크박스 + 버튼들 한 줄 배치 */} -
-
- setAutoCreateColumn(checked as boolean)} - /> - -
- -
- - - - -
-
- - {/* 하단: 감지된 범위 + 테이블 */} -
- 감지된 범위: {detectedRange} - - 첫 행이 컬럼명, 데이터는 자동 감지됩니다 - -
- - {displayData.length > 0 && ( -
- - - - - {excelColumns.map((col, index) => ( - - ))} - - - - - - {excelColumns.map((col) => ( - - ))} - - {displayData.map((row, rowIndex) => ( - - - {excelColumns.map((col) => ( - + {sheetName} + ))} - - ))} - -
- - - {String.fromCharCode(65 + index)} -
- 1 - - {col} -
- {rowIndex + 2} - 0 && ( + <> + {/* 시트 선택 */} +
+
+ +
-
+ + +
+ + {displayData.length}개 행 · 셀을 클릭하여 편집, Tab/Enter로 이동 + +
+ + {/* 엑셀처럼 편집 가능한 스프레드시트 */} + { + setExcelColumns(newColumns); + // 범위 재계산 + const lastCol = + newColumns.length > 0 + ? String.fromCharCode(64 + newColumns.length) + : "A"; + setDetectedRange(`A1:${lastCol}${displayData.length + 1}`); + }} + onDataChange={(newData) => { + setDisplayData(newData); + setAllData(newData); + // 범위 재계산 + const lastCol = + excelColumns.length > 0 + ? String.fromCharCode(64 + excelColumns.length) + : "A"; + setDetectedRange(`A1:${lastCol}${newData.length + 1}`); + }} + maxHeight="320px" + /> + )}
)} - {/* 3단계: 컬럼 매핑 */} - {currentStep === 3 && ( + {/* 2단계: 컬럼 매핑 */} + {currentStep === 2 && (
{/* 상단: 제목 + 자동 매핑 버튼 */}
@@ -693,9 +1163,12 @@ export const ExcelUploadModal: React.FC = ({
시스템 컬럼
-
+
{columnMappings.map((mapping, index) => ( -
+
{mapping.excelColumn}
@@ -713,7 +1186,9 @@ export const ExcelUploadModal: React.FC = ({ {mapping.systemColumn ? (() => { - const col = systemColumns.find(c => c.name === mapping.systemColumn); + const col = systemColumns.find( + (c) => c.name === mapping.systemColumn + ); return col?.label || mapping.systemColumn; })() : "매핑 안함"} @@ -738,11 +1213,40 @@ export const ExcelUploadModal: React.FC = ({ ))}
+ + {/* 매핑 자동 저장 안내 */} + {isAutoMappingLoaded ? ( +
+
+ +
+

이전 매핑이 자동 적용됨

+

+ 동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다. + 수정하면 업로드 시 자동 저장됩니다. +

+
+
+
+ ) : ( +
+
+ +
+

새로운 엑셀 구조

+

+ 이 엑셀 구조는 처음입니다. 매핑을 설정하면 다음에 같은 구조의 + 엑셀에 자동 적용됩니다. +

+
+
+
+ )}
)} - {/* 4단계: 확인 */} - {currentStep === 4 && ( + {/* 3단계: 확인 */} + {currentStep === 3 && (

업로드 요약

@@ -762,7 +1266,7 @@ export const ExcelUploadModal: React.FC = ({

모드:{" "} {uploadMode === "insert" - ? "삽입" + ? "신규 등록" : uploadMode === "update" ? "업데이트" : "Upsert"} @@ -775,12 +1279,17 @@ export const ExcelUploadModal: React.FC = ({

{columnMappings .filter((m) => m.systemColumn) - .map((mapping, index) => ( -

- {mapping.excelColumn} →{" "} - {mapping.systemColumn} -

- ))} + .map((mapping, index) => { + const col = systemColumns.find( + (c) => c.name === mapping.systemColumn + ); + return ( +

+ {mapping.excelColumn} →{" "} + {col?.label || mapping.systemColumn} +

+ ); + })} {columnMappings.filter((m) => m.systemColumn).length === 0 && (

매핑된 컬럼이 없습니다.

)} @@ -793,7 +1302,8 @@ export const ExcelUploadModal: React.FC = ({

주의사항

- 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까? + 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. + 계속하시겠습니까?

@@ -811,10 +1321,10 @@ export const ExcelUploadModal: React.FC = ({ > {currentStep === 1 ? "취소" : "이전"} - {currentStep < 4 ? ( + {currentStep < 3 ? ( )} diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index cceadae9..44685dc0 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -174,8 +174,24 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) if (editData) { console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); - setFormData(editData); - setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + + // 🆕 배열인 경우 두 가지 데이터를 설정: + // 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등) + // 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등) + if (Array.isArray(editData)) { + const firstRecord = editData[0] || {}; + console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, { + formData: "첫 번째 레코드 (일반 입력 필드용)", + selectedData: "전체 배열 (다중 항목 컴포넌트용)", + }); + setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체) + setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨 + setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장 + } else { + setFormData(editData); + setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장 + setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + } } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 @@ -261,7 +277,7 @@ export const ScreenModal: React.FC = ({ className }) => { // dataSourceId 파라미터 제거 currentUrl.searchParams.delete("dataSourceId"); window.history.pushState({}, "", currentUrl.toString()); - console.log("🧹 URL 파라미터 제거"); + // console.log("🧹 URL 파라미터 제거"); } setModalState({ @@ -277,7 +293,7 @@ export const ScreenModal: React.FC = ({ className }) => { setSelectedData([]); // 🆕 선택된 데이터 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 - console.log("🔄 연속 모드 초기화: false"); + // console.log("🔄 연속 모드 초기화: false"); }; // 저장 성공 이벤트 처리 (연속 등록 모드 지원) @@ -285,36 +301,36 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지) const timeSinceOpen = Date.now() - modalOpenedAtRef.current; if (timeSinceOpen < 500) { - console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen }); + // console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen }); return; } const isContinuousMode = continuousMode; - console.log("💾 저장 성공 이벤트 수신"); - console.log("📌 현재 연속 모드 상태:", isContinuousMode); - console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); + // console.log("💾 저장 성공 이벤트 수신"); + // console.log("📌 현재 연속 모드 상태:", isContinuousMode); + // console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 - console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋"); + // console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋"); // 1. 폼 데이터 초기화 setFormData({}); // 2. 리셋 키 변경 (컴포넌트 강제 리마운트) setResetKey((prev) => prev + 1); - console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); + // console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); // 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성) if (modalState.screenId) { - console.log("🔄 화면 데이터 다시 로드:", modalState.screenId); + // console.log("🔄 화면 데이터 다시 로드:", modalState.screenId); loadScreenData(modalState.screenId); } toast.success("저장되었습니다. 계속 입력하세요."); } else { // 일반 모드: 모달 닫기 - console.log("❌ 일반 모드 - 모달 닫기"); + // console.log("❌ 일반 모드 - 모달 닫기"); handleCloseModal(); } }; diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx index 5418fcab..4cf5e32d 100644 --- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx +++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx @@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record = { NOT_IN: "NOT IN", IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL", + EXISTS_IN: "EXISTS IN", + NOT_EXISTS_IN: "NOT EXISTS IN", +}; + +// EXISTS 계열 연산자인지 확인 +const isExistsOperator = (operator: string): boolean => { + return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; }; export const ConditionNode = memo(({ data, selected }: NodeProps) => { @@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps 0 && (
{data.logic}
)} -
+
{condition.field} - + {OPERATOR_LABELS[condition.operator] || condition.operator} - {condition.value !== null && condition.value !== undefined && ( - - {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + {/* EXISTS 연산자인 경우 테이블.필드 표시 */} + {isExistsOperator(condition.operator) ? ( + + {(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."} + {(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`} + ) : ( + // 일반 연산자인 경우 값 표시 + condition.value !== null && + condition.value !== undefined && ( + + {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + + ) )}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index 87f7f771..a2d060d4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -4,14 +4,18 @@ * 조건 분기 노드 속성 편집 */ -import { useEffect, useState } from "react"; -import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState, useCallback } from "react"; +import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; -import type { ConditionNodeData } from "@/types/node-editor"; +import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; // 필드 정의 interface FieldDefinition { @@ -20,6 +24,19 @@ interface FieldDefinition { type?: string; } +// 테이블 정보 +interface TableInfo { + tableName: string; + tableLabel: string; +} + +// 테이블 컬럼 정보 +interface ColumnInfo { + columnName: string; + columnLabel: string; + dataType: string; +} + interface ConditionPropertiesProps { nodeId: string; data: ConditionNodeData; @@ -38,8 +55,194 @@ const OPERATORS = [ { value: "NOT_IN", label: "NOT IN" }, { value: "IS_NULL", label: "NULL" }, { value: "IS_NOT_NULL", label: "NOT NULL" }, + { value: "EXISTS_IN", label: "다른 테이블에 존재함" }, + { value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" }, ] as const; +// EXISTS 계열 연산자인지 확인 +const isExistsOperator = (operator: string): boolean => { + return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; +}; + +// 테이블 선택용 검색 가능한 Combobox +function TableCombobox({ + tables, + value, + onSelect, + placeholder = "테이블 검색...", +}: { + tables: TableInfo[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const selectedTable = tables.find((t) => t.tableName === value); + + return ( + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 컬럼 선택용 검색 가능한 Combobox +function ColumnCombobox({ + columns, + value, + onSelect, + placeholder = "컬럼 검색...", +}: { + columns: ColumnInfo[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const selectedColumn = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {columns.map((col) => ( + { + onSelect(col.columnName); + setOpen(false); + }} + className="text-xs" + > + + {col.columnLabel} + ({col.columnName}) + + ))} + + + + + + ); +} + +// 컬럼 선택 섹션 (자동 로드 포함) +function ColumnSelectSection({ + lookupTable, + lookupField, + tableColumnsCache, + loadingColumns, + loadTableColumns, + onSelect, +}: { + lookupTable: string; + lookupField: string; + tableColumnsCache: Record; + loadingColumns: Record; + loadTableColumns: (tableName: string) => Promise; + onSelect: (value: string) => void; +}) { + // 캐시에 없고 로딩 중이 아니면 자동으로 로드 + useEffect(() => { + if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) { + loadTableColumns(lookupTable); + } + }, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]); + + const isLoading = loadingColumns[lookupTable]; + const columns = tableColumnsCache[lookupTable]; + + return ( +
+ + {isLoading ? ( +
+ 컬럼 목록 로딩 중... +
+ ) : columns && columns.length > 0 ? ( + + ) : ( +
+ 컬럼 목록을 로드할 수 없습니다 +
+ )} +
+ ); +} + export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) { const { updateNode, nodes, edges } = useFlowEditorStore(); @@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // EXISTS 연산자용 상태 + const [allTables, setAllTables] = useState([]); + const [tableColumnsCache, setTableColumnsCache] = useState>({}); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState>({}); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || "조건 분기"); @@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setLogic(data.logic || "AND"); }, [data]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) + useEffect(() => { + const loadAllTables = async () => { + // 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵 + if (allTables.length > 0) return; + + // EXISTS 연산자가 하나라도 있으면 테이블 목록 로드 + const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator)); + if (!hasExistsOperator) return; + + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName, + tableLabel: t.tableLabel || t.tableName, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + + loadAllTables(); + }, [conditions, allTables.length]); + + // 테이블 컬럼 로드 함수 + const loadTableColumns = useCallback( + async (tableName: string): Promise => { + // 캐시에 있으면 반환 + if (tableColumnsCache[tableName]) { + return tableColumnsCache[tableName]; + } + + // 이미 로딩 중이면 스킵 + if (loadingColumns[tableName]) { + return []; + } + + // 로딩 상태 설정 + setLoadingColumns((prev) => ({ ...prev, [tableName]: true })); + + try { + // getColumnList 반환: { success, data: { columns, total, ... } } + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data && response.data.columns) { + const columns = response.data.columns.map((c: any) => ({ + columnName: c.columnName, + columnLabel: c.columnLabel || c.columnName, + dataType: c.dataType, + })); + setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개"); + return columns; + } else { + console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response); + } + } catch (error) { + console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error); + } finally { + setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); + } + return []; + }, + [tableColumnsCache, loadingColumns] + ); + + // EXISTS 연산자 선택 시 테이블 목록 강제 로드 + const ensureTablesLoaded = useCallback(async () => { + if (allTables.length > 0) return; + + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName, + tableLabel: t.tableLabel || t.tableName, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }, [allTables.length]); + // 🔥 연결된 소스 노드의 필드를 재귀적으로 수집 useEffect(() => { const getAllSourceFields = (currentNodeId: string, visited: Set = new Set()): FieldDefinition[] => { @@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }, [nodeId, nodes, edges]); const handleAddCondition = () => { - setConditions([ - ...conditions, - { - field: "", - operator: "EQUALS", - value: "", - valueType: "static", // "static" (고정값) 또는 "field" (필드 참조) - }, - ]); + const newCondition = { + field: "", + operator: "EQUALS" as ConditionOperator, + value: "", + valueType: "static" as "static" | "field", + // EXISTS 연산자용 필드는 초기값 없음 + lookupTable: undefined, + lookupTableLabel: undefined, + lookupField: undefined, + lookupFieldLabel: undefined, + }; + setConditions([...conditions, newCondition]); }; const handleRemoveCondition = (index: number) => { @@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; - const handleConditionChange = (index: number, field: string, value: any) => { + const handleConditionChange = async (index: number, field: string, value: any) => { const newConditions = [...conditions]; newConditions[index] = { ...newConditions[index], [field]: value }; + + // EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화 + if (field === "operator" && isExistsOperator(value)) { + await ensureTablesLoaded(); + // EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화 + newConditions[index].value = ""; + newConditions[index].valueType = undefined; + } + + // EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화 + if (field === "operator" && !isExistsOperator(value)) { + newConditions[index].lookupTable = undefined; + newConditions[index].lookupTableLabel = undefined; + newConditions[index].lookupField = undefined; + newConditions[index].lookupFieldLabel = undefined; + } + + // lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정 + if (field === "lookupTable" && value) { + const tableInfo = allTables.find((t) => t.tableName === value); + if (tableInfo) { + newConditions[index].lookupTableLabel = tableInfo.tableLabel; + } + // 테이블 변경 시 필드 초기화 + newConditions[index].lookupField = undefined; + newConditions[index].lookupFieldLabel = undefined; + // 컬럼 목록 미리 로드 + await loadTableColumns(value); + } + + // lookupField 변경 시 라벨 설정 + if (field === "lookupField" && value) { + const tableName = newConditions[index].lookupTable; + if (tableName && tableColumnsCache[tableName]) { + const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value); + if (columnInfo) { + newConditions[index].lookupFieldLabel = columnInfo.columnLabel; + } + } + } + setConditions(newConditions); updateNode(nodeId, { conditions: newConditions, @@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
- {condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && ( + {/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */} + {isExistsOperator(condition.operator) && ( <>
- - + + {loadingTables ? ( +
+ 테이블 목록 로딩 중... +
+ ) : allTables.length > 0 ? ( + handleConditionChange(index, "lookupTable", value)} + placeholder="테이블 검색..." + /> + ) : ( +
+ 테이블 목록을 로드할 수 없습니다 +
+ )}
-
- - {(condition as any).valueType === "field" ? ( - // 필드 참조: 드롭다운으로 선택 - availableFields.length > 0 ? ( - - ) : ( -
- 소스 노드를 연결하세요 -
- ) - ) : ( - // 고정값: 직접 입력 - handleConditionChange(index, "value", e.target.value)} - placeholder="비교할 값" - className="mt-1 h-8 text-xs" - /> - )} + {(condition as any).lookupTable && ( + handleConditionChange(index, "lookupField", value)} + /> + )} + +
+ {condition.operator === "EXISTS_IN" + ? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE` + : `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
)} + + {/* 일반 연산자인 경우: 기존 비교값 UI */} + {condition.operator !== "IS_NULL" && + condition.operator !== "IS_NOT_NULL" && + !isExistsOperator(condition.operator) && ( + <> +
+ + +
+ +
+ + {(condition as any).valueType === "field" ? ( + // 필드 참조: 드롭다운으로 선택 + availableFields.length > 0 ? ( + + ) : ( +
+ 소스 노드를 연결하세요 +
+ ) + ) : ( + // 고정값: 직접 입력 + handleConditionChange(index, "value", e.target.value)} + placeholder="비교할 값" + className="mt-1 h-8 text-xs" + /> + )} +
+ + )}
))} @@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {/* 안내 */}
- 🔌 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다. + 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
- 🔄 비교 값 타입:
고정값: 직접 입력한 값과 비교 (예: age > 30) -
필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량) + 비교 값 타입:
+ - 고정값: 직접 입력한 값과 비교 (예: age > 30) +
- 필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량) +
+
+ 테이블 존재 여부 검사:
+ - 다른 테이블에 존재함: 값이 다른 테이블에 있으면 TRUE +
- 다른 테이블에 존재하지 않음: 값이 다른 테이블에 없으면 TRUE +
+ (예: 품명이 품목정보 테이블에 없으면 자동 등록)
- 💡 AND: 모든 조건이 참이어야 TRUE 출력 + AND: 모든 조건이 참이어야 TRUE 출력
- 💡 OR: 하나라도 참이면 TRUE 출력 + OR: 하나라도 참이면 TRUE 출력
- ⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다. + TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index 16eca3cd..b30bc1f4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -4,7 +4,7 @@ * DELETE 액션 노드 속성 편집 */ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps { data: DeleteActionNodeData; } +// 소스 필드 타입 +interface SourceField { + name: string; + label?: string; +} + const OPERATORS = [ { value: "EQUALS", label: "=" }, { value: "NOT_EQUALS", label: "≠" }, @@ -34,7 +40,7 @@ const OPERATORS = [ ] as const; export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) { - const { updateNode, getExternalConnectionsCache } = useFlowEditorStore(); + const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore(); // 🔥 타겟 타입 상태 const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); @@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP const [targetTable, setTargetTable] = useState(data.targetTable); const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); + // 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기) + const [sourceFields, setSourceFields] = useState([]); + const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState([]); + // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); @@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP // whereConditions 변경 시 fieldOpenState 초기화 useEffect(() => { setFieldOpenState(new Array(whereConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(whereConditions.length).fill(false)); }, [whereConditions.length]); + // 🆕 소스 필드 로딩 (연결된 입력 노드에서) + const loadSourceFields = useCallback(async () => { + // 현재 노드로 연결된 엣지 찾기 + const incomingEdges = edges.filter((e) => e.target === nodeId); + console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges); + + if (incomingEdges.length === 0) { + console.log("⚠️ 연결된 소스 노드가 없습니다"); + setSourceFields([]); + return; + } + + const fields: SourceField[] = []; + const processedFields = new Set(); + + for (const edge of incomingEdges) { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (!sourceNode) continue; + + console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data); + + // 소스 노드 타입에 따라 필드 추출 + if (sourceNode.type === "trigger" && sourceNode.data.tableName) { + // 트리거 노드: 테이블 컬럼 조회 + try { + const columns = await tableTypeApi.getColumns(sourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("트리거 노드 컬럼 로딩 실패:", error); + } + } else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) { + // 테이블 소스 노드 + try { + const columns = await tableTypeApi.getColumns(sourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("테이블 소스 노드 컬럼 로딩 실패:", error); + } + } else if (sourceNode.type === "condition") { + // 조건 노드: 연결된 이전 노드에서 필드 가져오기 + const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id); + for (const condEdge of conditionIncomingEdges) { + const condSourceNode = nodes.find((n) => n.id === condEdge.source); + if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) { + try { + const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("조건 노드 소스 컬럼 로딩 실패:", error); + } + } + } + } + } + + console.log("✅ DELETE 노드 소스 필드:", fields); + setSourceFields(fields); + }, [nodeId, nodes, edges]); + + // 소스 필드 로딩 + useEffect(() => { + loadSourceFields(); + }, [loadSourceFields]); + const loadExternalConnections = async () => { try { setExternalConnectionsLoading(true); @@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP field: "", operator: "EQUALS", value: "", + sourceField: undefined, + staticValue: undefined, }, ]; setWhereConditions(newConditions); setFieldOpenState(new Array(newConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleRemoveCondition = (index: number) => { const newConditions = whereConditions.filter((_, i) => i !== index); setWhereConditions(newConditions); setFieldOpenState(new Array(newConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleConditionChange = (index: number, field: string, value: any) => { const newConditions = [...whereConditions]; newConditions[index] = { ...newConditions[index], [field]: value }; setWhereConditions(newConditions); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleSave = () => { @@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
+ {/* 🆕 소스 필드 - Combobox */}
- + + {sourceFields.length > 0 ? ( + { + const newState = [...sourceFieldsOpenState]; + newState[index] = open; + setSourceFieldsOpenState(newState); + }} + > + + + + + + + + 필드를 찾을 수 없습니다. + + { + handleConditionChange(index, "sourceField", undefined); + const newState = [...sourceFieldsOpenState]; + newState[index] = false; + setSourceFieldsOpenState(newState); + }} + className="text-xs text-gray-400 sm:text-sm" + > + + 없음 (정적 값 사용) + + {sourceFields.map((field) => ( + { + handleConditionChange(index, "sourceField", currentValue); + const newState = [...sourceFieldsOpenState]; + newState[index] = false; + setSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+
+ ) : ( +
+ 연결된 소스 노드가 없습니다 +
+ )} +

소스 데이터에서 값을 가져올 필드

+
+ + {/* 정적 값 */} +
+ handleConditionChange(index, "value", e.target.value)} - placeholder="비교 값" + value={condition.staticValue || condition.value || ""} + onChange={(e) => { + handleConditionChange(index, "staticValue", e.target.value || undefined); + handleConditionChange(index, "value", e.target.value); + }} + placeholder="비교할 고정 값" className="mt-1 h-8 text-xs" /> +

소스 필드가 비어있을 때 사용됩니다

diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 437487e9..c68ff8d4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from "react"; -import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react"; +import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -18,6 +18,8 @@ import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; +import { getNumberingRules } from "@/lib/api/numberingRule"; +import type { NumberingRuleConfig } from "@/types/numbering-rule"; import type { InsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -89,6 +91,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const [apiHeaders, setApiHeaders] = useState>(data.apiHeaders || {}); const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || ""); + // 🔥 채번 규칙 관련 상태 + const [numberingRules, setNumberingRules] = useState([]); + const [numberingRulesLoading, setNumberingRulesLoading] = useState(false); + const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState([]); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || data.targetTable); @@ -128,8 +135,33 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP useEffect(() => { setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false)); }, [fieldMappings.length]); + // 🔥 채번 규칙 로딩 (자동 생성 사용 시) + useEffect(() => { + const loadNumberingRules = async () => { + setNumberingRulesLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setNumberingRules(response.data); + console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`); + } else { + console.error("❌ 채번 규칙 로딩 실패:", response.error); + setNumberingRules([]); + } + } catch (error) { + console.error("❌ 채번 규칙 로딩 오류:", error); + setNumberingRules([]); + } finally { + setNumberingRulesLoading(false); + } + }; + + loadNumberingRules(); + }, []); + // 🔥 외부 테이블 변경 시 컬럼 로드 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { @@ -540,6 +572,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP sourceField: null, targetField: "", staticValue: undefined, + valueType: "source" as const, // 🔥 기본값: 소스 필드 }, ]; setFieldMappings(newMappings); @@ -548,6 +581,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // Combobox 열림 상태 배열 초기화 setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false)); }; const handleRemoveMapping = (index: number) => { @@ -558,6 +592,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // Combobox 열림 상태 배열도 업데이트 setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false)); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -586,6 +621,24 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP targetField: value, targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value, }; + } else if (field === "valueType") { + // 🔥 값 생성 유형 변경 시 관련 필드 초기화 + newMappings[index] = { + ...newMappings[index], + valueType: value, + // 유형 변경 시 다른 유형의 값 초기화 + ...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }), + ...(value !== "static" && { staticValue: undefined }), + ...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }), + }; + } else if (field === "numberingRuleId") { + // 🔥 채번 규칙 선택 시 이름도 함께 저장 + const selectedRule = numberingRules.find((r) => r.ruleId === value); + newMappings[index] = { + ...newMappings[index], + numberingRuleId: value, + numberingRuleName: selectedRule?.ruleName, + }; } else { newMappings[index] = { ...newMappings[index], @@ -1165,54 +1218,203 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- {/* 소스 필드 입력/선택 */} + {/* 🔥 값 생성 유형 선택 */}
- - {hasRestAPISource ? ( - // REST API 소스인 경우: 직접 입력 + +
+ + + +
+
+ + {/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */} + {(mapping.valueType === "source" || !mapping.valueType) && ( +
+ + {hasRestAPISource ? ( + // REST API 소스인 경우: 직접 입력 + handleMappingChange(index, "sourceField", e.target.value || null)} + placeholder="필드명 입력 (예: userId, userName)" + className="mt-1 h-8 text-xs" + /> + ) : ( + // 일반 소스인 경우: Combobox 선택 + { + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = open; + setMappingSourceFieldsOpenState(newState); + }} + > + + + + + + + + + 필드를 찾을 수 없습니다. + + + {sourceFields.map((field) => ( + { + handleMappingChange(index, "sourceField", currentValue || null); + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = false; + setMappingSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+
+ )} + {hasRestAPISource && ( +

API 응답 JSON의 필드명을 입력하세요

+ )} +
+ )} + + {/* 🔥 고정값 입력 (valueType === "static" 일 때) */} + {mapping.valueType === "static" && ( +
+ handleMappingChange(index, "sourceField", e.target.value || null)} - placeholder="필드명 입력 (예: userId, userName)" + value={mapping.staticValue || ""} + onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)} + placeholder="고정값 입력" className="mt-1 h-8 text-xs" /> - ) : ( - // 일반 소스인 경우: Combobox 선택 +
+ )} + + {/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */} + {mapping.valueType === "autoGenerate" && ( +
+ { - const newState = [...mappingSourceFieldsOpenState]; + const newState = [...mappingNumberingRulesOpenState]; newState[index] = open; - setMappingSourceFieldsOpenState(newState); + setMappingNumberingRulesOpenState(newState); }} > @@ -1222,37 +1424,36 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP align="start" > - + - 필드를 찾을 수 없습니다. + 채번 규칙을 찾을 수 없습니다. - {sourceFields.map((field) => ( + {numberingRules.map((rule) => ( { - handleMappingChange(index, "sourceField", currentValue || null); - const newState = [...mappingSourceFieldsOpenState]; + handleMappingChange(index, "numberingRuleId", currentValue); + const newState = [...mappingNumberingRulesOpenState]; newState[index] = false; - setMappingSourceFieldsOpenState(newState); + setMappingNumberingRulesOpenState(newState); }} className="text-xs sm:text-sm" >
- {field.label || field.name} - {field.label && field.label !== field.name && ( - - {field.name} - - )} + {rule.ruleName} + + {rule.ruleId} + {rule.tableName && ` - ${rule.tableName}`} +
))} @@ -1261,11 +1462,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- )} - {hasRestAPISource && ( -

API 응답 JSON의 필드명을 입력하세요

- )} -
+ {numberingRules.length === 0 && !numberingRulesLoading && ( +

+ 등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요. +

+ )} +
+ )}
@@ -1400,18 +1603,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- - {/* 정적 값 */} -
- - handleMappingChange(index, "staticValue", e.target.value || undefined)} - placeholder="소스 필드 대신 고정 값 사용" - className="mt-1 h-8 text-xs" - /> -

소스 필드가 비어있을 때만 사용됩니다

-
))} @@ -1428,9 +1619,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP {/* 안내 */}
- ✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다. -
- 💡 소스 필드가 없으면 정적 값이 사용됩니다. +

테이블과 필드는 실제 데이터베이스에서 조회됩니다.

+

값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)

diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 449a9c49..236071ac 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(() => { @@ -335,8 +372,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { // 모드 전환 핸들러 const handleModeSwitch = () => { if (isAdminMode) { + // 관리자 → 사용자 모드: 선택한 회사 유지 router.push("/main"); } else { + // 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음) router.push("/admin"); } }; @@ -498,11 +537,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 +719,21 @@ function AppLayoutInner({ children }: AppLayoutProps) { onSave={saveProfile} onAlertClose={closeAlert} /> + + {/* 회사 전환 모달 (WACE 관리자 전용) */} + + + + 회사 선택 + + 관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다. + + +
+ setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} /> +
+
+
); } diff --git a/frontend/components/mail/MailDetailModal.tsx b/frontend/components/mail/MailDetailModal.tsx index 0a25c2a3..31c2d7c0 100644 --- a/frontend/components/mail/MailDetailModal.tsx +++ b/frontend/components/mail/MailDetailModal.tsx @@ -250,7 +250,7 @@ export default function MailDetailModal({ originalDate: mail.date, originalBody: mail.body, }; - router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`); + router.push(`/admin/automaticMng/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`); onClose(); }} > @@ -270,7 +270,7 @@ export default function MailDetailModal({ originalBody: mail.body, originalAttachments: mail.attachments, }; - router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`); + router.push(`/admin/automaticMng/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`); onClose(); }} > diff --git a/frontend/components/pop/PopAcceptModal.tsx b/frontend/components/pop/PopAcceptModal.tsx new file mode 100644 index 00000000..06f1759e --- /dev/null +++ b/frontend/components/pop/PopAcceptModal.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { X, Info } from "lucide-react"; +import { WorkOrder } from "./types"; + +interface PopAcceptModalProps { + isOpen: boolean; + workOrder: WorkOrder | null; + quantity: number; + onQuantityChange: (qty: number) => void; + onConfirm: (quantity: number) => void; + onClose: () => void; +} + +export function PopAcceptModal({ + isOpen, + workOrder, + quantity, + onQuantityChange, + onConfirm, + onClose, +}: PopAcceptModalProps) { + if (!isOpen || !workOrder) return null; + + const acceptedQty = workOrder.acceptedQuantity || 0; + const remainingQty = workOrder.orderQuantity - acceptedQty; + + const handleAdjust = (delta: number) => { + const newQty = Math.max(1, Math.min(quantity + delta, remainingQty)); + onQuantityChange(newQty); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const val = parseInt(e.target.value) || 0; + const newQty = Math.max(0, Math.min(val, remainingQty)); + onQuantityChange(newQty); + }; + + const handleConfirm = () => { + if (quantity > 0) { + onConfirm(quantity); + } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

작업 접수

+ +
+ +
+
+ {/* 작업지시 정보 */} +
+
{workOrder.id}
+
+ {workOrder.itemName} ({workOrder.spec}) +
+
+ 지시수량: {workOrder.orderQuantity} EA | 기 접수: {acceptedQty} EA +
+
+ + {/* 수량 입력 */} +
+ +
+ + + + + +
+
미접수 수량: {remainingQty} EA
+
+ + {/* 분할접수 안내 */} + {quantity < remainingQty && ( +
+ + + +
+
분할 접수
+
+ {quantity}EA 접수 후 {remainingQty - quantity}EA가 접수대기 상태로 남습니다. +
+
+
+ )} +
+
+ +
+ + +
+
+
+ ); +} + diff --git a/frontend/components/pop/PopApp.tsx b/frontend/components/pop/PopApp.tsx new file mode 100644 index 00000000..b1eb6551 --- /dev/null +++ b/frontend/components/pop/PopApp.tsx @@ -0,0 +1,462 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import "./styles.css"; + +import { + AppState, + ModalState, + PanelState, + StatusType, + ProductionType, + WorkOrder, + WorkStep, + Equipment, + Process, +} from "./types"; +import { WORK_ORDERS, EQUIPMENTS, PROCESSES, WORK_STEP_TEMPLATES, STATUS_TEXT } from "./data"; + +import { PopHeader } from "./PopHeader"; +import { PopStatusTabs } from "./PopStatusTabs"; +import { PopWorkCard } from "./PopWorkCard"; +import { PopBottomNav } from "./PopBottomNav"; +import { PopEquipmentModal } from "./PopEquipmentModal"; +import { PopProcessModal } from "./PopProcessModal"; +import { PopAcceptModal } from "./PopAcceptModal"; +import { PopSettingsModal } from "./PopSettingsModal"; +import { PopProductionPanel } from "./PopProductionPanel"; + +export function PopApp() { + // 앱 상태 + const [appState, setAppState] = useState({ + currentStatus: "waiting", + selectedEquipment: null, + selectedProcess: null, + selectedWorkOrder: null, + showMyWorkOnly: false, + currentWorkSteps: [], + currentStepIndex: 0, + currentProductionType: "work-order", + selectionMode: "single", + completionAction: "close", + acceptTargetWorkOrder: null, + acceptQuantity: 0, + theme: "dark", + }); + + // 모달 상태 + const [modalState, setModalState] = useState({ + equipment: false, + process: false, + accept: false, + settings: false, + }); + + // 패널 상태 + const [panelState, setPanelState] = useState({ + production: false, + }); + + // 현재 시간 (hydration 에러 방지를 위해 초기값 null) + const [currentDateTime, setCurrentDateTime] = useState(null); + const [isClient, setIsClient] = useState(false); + + // 작업지시 목록 (상태 변경을 위해 로컬 상태로 관리) + const [workOrders, setWorkOrders] = useState(WORK_ORDERS); + + // 클라이언트 마운트 확인 및 시계 업데이트 + useEffect(() => { + setIsClient(true); + setCurrentDateTime(new Date()); + + const timer = setInterval(() => { + setCurrentDateTime(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + + // 로컬 스토리지에서 설정 로드 + useEffect(() => { + const savedSelectionMode = localStorage.getItem("selectionMode") as "single" | "multi" | null; + const savedCompletionAction = localStorage.getItem("completionAction") as "close" | "stay" | null; + const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null; + + setAppState((prev) => ({ + ...prev, + selectionMode: savedSelectionMode || "single", + completionAction: savedCompletionAction || "close", + theme: savedTheme || "dark", + })); + }, []); + + // 상태별 카운트 계산 + const getStatusCounts = useCallback(() => { + const myProcessId = appState.selectedProcess?.id; + + let waitingCount = 0; + let pendingAcceptCount = 0; + let inProgressCount = 0; + let completedCount = 0; + + workOrders.forEach((wo) => { + if (!wo.processFlow) return; + + const myProcessIndex = myProcessId + ? wo.processFlow.findIndex((step) => step.id === myProcessId) + : -1; + + if (wo.status === "completed") { + completedCount++; + } else if (wo.status === "in-progress" && wo.accepted) { + inProgressCount++; + } else if (myProcessIndex >= 0) { + const currentProcessIndex = wo.currentProcessIndex || 0; + const myStep = wo.processFlow[myProcessIndex]; + + if (currentProcessIndex < myProcessIndex) { + waitingCount++; + } else if (currentProcessIndex === myProcessIndex && myStep.status !== "completed") { + pendingAcceptCount++; + } else if (myStep.status === "completed") { + completedCount++; + } + } else { + if (wo.status === "waiting") waitingCount++; + else if (wo.status === "in-progress") inProgressCount++; + } + }); + + return { waitingCount, pendingAcceptCount, inProgressCount, completedCount }; + }, [workOrders, appState.selectedProcess]); + + // 필터링된 작업 목록 + const getFilteredWorkOrders = useCallback(() => { + const myProcessId = appState.selectedProcess?.id; + let filtered: WorkOrder[] = []; + + workOrders.forEach((wo) => { + if (!wo.processFlow) return; + + const myProcessIndex = myProcessId + ? wo.processFlow.findIndex((step) => step.id === myProcessId) + : -1; + const currentProcessIndex = wo.currentProcessIndex || 0; + const myStep = myProcessIndex >= 0 ? wo.processFlow[myProcessIndex] : null; + + switch (appState.currentStatus) { + case "waiting": + if (myProcessIndex >= 0 && currentProcessIndex < myProcessIndex) { + filtered.push(wo); + } else if (!myProcessId && wo.status === "waiting") { + filtered.push(wo); + } + break; + + case "pending-accept": + if ( + myProcessIndex >= 0 && + currentProcessIndex === myProcessIndex && + myStep && + myStep.status !== "completed" && + !wo.accepted + ) { + filtered.push(wo); + } + break; + + case "in-progress": + if (wo.accepted && wo.status === "in-progress") { + filtered.push(wo); + } else if (!myProcessId && wo.status === "in-progress") { + filtered.push(wo); + } + break; + + case "completed": + if (wo.status === "completed") { + filtered.push(wo); + } else if (myStep && myStep.status === "completed") { + filtered.push(wo); + } + break; + } + }); + + // 내 작업만 보기 필터 + if (appState.showMyWorkOnly && myProcessId) { + filtered = filtered.filter((wo) => { + const mySteps = wo.processFlow.filter((step) => step.id === myProcessId); + if (mySteps.length === 0) return false; + return !mySteps.every((step) => step.status === "completed"); + }); + } + + return filtered; + }, [workOrders, appState.currentStatus, appState.selectedProcess, appState.showMyWorkOnly]); + + // 상태 탭 변경 + const handleStatusChange = (status: StatusType) => { + setAppState((prev) => ({ ...prev, currentStatus: status })); + }; + + // 생산 유형 변경 + const handleProductionTypeChange = (type: ProductionType) => { + setAppState((prev) => ({ ...prev, currentProductionType: type })); + }; + + // 내 작업만 보기 토글 + const handleMyWorkToggle = () => { + setAppState((prev) => ({ ...prev, showMyWorkOnly: !prev.showMyWorkOnly })); + }; + + // 테마 토글 + const handleThemeToggle = () => { + const newTheme = appState.theme === "dark" ? "light" : "dark"; + setAppState((prev) => ({ ...prev, theme: newTheme })); + localStorage.setItem("popTheme", newTheme); + }; + + // 모달 열기/닫기 + const openModal = (type: keyof ModalState) => { + setModalState((prev) => ({ ...prev, [type]: true })); + }; + + const closeModal = (type: keyof ModalState) => { + setModalState((prev) => ({ ...prev, [type]: false })); + }; + + // 설비 선택 + const handleEquipmentSelect = (equipment: Equipment) => { + setAppState((prev) => ({ + ...prev, + selectedEquipment: equipment, + // 공정이 1개면 자동 선택 + selectedProcess: + equipment.processIds.length === 1 + ? PROCESSES.find((p) => p.id === equipment.processIds[0]) || null + : null, + })); + }; + + // 공정 선택 + const handleProcessSelect = (process: Process) => { + setAppState((prev) => ({ ...prev, selectedProcess: process })); + }; + + // 작업 접수 모달 열기 + const handleOpenAcceptModal = (workOrder: WorkOrder) => { + const acceptedQty = workOrder.acceptedQuantity || 0; + const remainingQty = workOrder.orderQuantity - acceptedQty; + + setAppState((prev) => ({ + ...prev, + acceptTargetWorkOrder: workOrder, + acceptQuantity: remainingQty, + })); + openModal("accept"); + }; + + // 접수 확인 + const handleConfirmAccept = (quantity: number) => { + if (!appState.acceptTargetWorkOrder) return; + + setWorkOrders((prev) => + prev.map((wo) => { + if (wo.id === appState.acceptTargetWorkOrder!.id) { + const previousAccepted = wo.acceptedQuantity || 0; + const newAccepted = previousAccepted + quantity; + return { + ...wo, + acceptedQuantity: newAccepted, + remainingQuantity: wo.orderQuantity - newAccepted, + accepted: true, + status: "in-progress" as const, + isPartialAccept: newAccepted < wo.orderQuantity, + }; + } + return wo; + }) + ); + + closeModal("accept"); + setAppState((prev) => ({ + ...prev, + acceptTargetWorkOrder: null, + acceptQuantity: 0, + })); + }; + + // 접수 취소 + const handleCancelAccept = (workOrderId: string) => { + setWorkOrders((prev) => + prev.map((wo) => { + if (wo.id === workOrderId) { + return { + ...wo, + accepted: false, + acceptedQuantity: 0, + remainingQuantity: wo.orderQuantity, + isPartialAccept: false, + status: "waiting" as const, + }; + } + return wo; + }) + ); + }; + + // 생산진행 패널 열기 + const handleOpenProductionPanel = (workOrder: WorkOrder) => { + const template = WORK_STEP_TEMPLATES[workOrder.process] || WORK_STEP_TEMPLATES["default"]; + const workSteps: WorkStep[] = template.map((step) => ({ + ...step, + status: "pending" as const, + startTime: null, + endTime: null, + data: {}, + })); + + setAppState((prev) => ({ + ...prev, + selectedWorkOrder: workOrder, + currentWorkSteps: workSteps, + currentStepIndex: 0, + })); + setPanelState((prev) => ({ ...prev, production: true })); + }; + + // 생산진행 패널 닫기 + const handleCloseProductionPanel = () => { + setPanelState((prev) => ({ ...prev, production: false })); + setAppState((prev) => ({ + ...prev, + selectedWorkOrder: null, + currentWorkSteps: [], + currentStepIndex: 0, + })); + }; + + // 설정 저장 + const handleSaveSettings = (selectionMode: "single" | "multi", completionAction: "close" | "stay") => { + setAppState((prev) => ({ ...prev, selectionMode, completionAction })); + localStorage.setItem("selectionMode", selectionMode); + localStorage.setItem("completionAction", completionAction); + closeModal("settings"); + }; + + const statusCounts = getStatusCounts(); + const filteredWorkOrders = getFilteredWorkOrders(); + + return ( +
+
+ {/* 헤더 */} + openModal("equipment")} + onProcessClick={() => openModal("process")} + onMyWorkToggle={handleMyWorkToggle} + onSearchClick={() => { + /* 조회 */ + }} + onSettingsClick={() => openModal("settings")} + onThemeToggle={handleThemeToggle} + /> + + {/* 상태 탭 */} + + + {/* 메인 콘텐츠 */} +
+ {filteredWorkOrders.length === 0 ? ( +
+
작업이 없습니다
+
+ {appState.currentStatus === "waiting" && "대기 중인 작업이 없습니다"} + {appState.currentStatus === "pending-accept" && "접수 대기 작업이 없습니다"} + {appState.currentStatus === "in-progress" && "진행 중인 작업이 없습니다"} + {appState.currentStatus === "completed" && "완료된 작업이 없습니다"} +
+
+ ) : ( +
+ {filteredWorkOrders.map((workOrder) => ( + handleOpenAcceptModal(workOrder)} + onCancelAccept={() => handleCancelAccept(workOrder.id)} + onStartProduction={() => handleOpenProductionPanel(workOrder)} + onClick={() => handleOpenProductionPanel(workOrder)} + /> + ))} +
+ )} +
+ + {/* 하단 네비게이션 */} + +
+ + {/* 모달들 */} + closeModal("equipment")} + /> + + closeModal("process")} + /> + + setAppState((prev) => ({ ...prev, acceptQuantity: qty }))} + onConfirm={handleConfirmAccept} + onClose={() => closeModal("accept")} + /> + + closeModal("settings")} + /> + + {/* 생산진행 패널 */} + setAppState((prev) => ({ ...prev, currentStepIndex: index }))} + onStepsUpdate={(steps) => setAppState((prev) => ({ ...prev, currentWorkSteps: steps }))} + onClose={handleCloseProductionPanel} + /> +
+ ); +} + diff --git a/frontend/components/pop/PopBottomNav.tsx b/frontend/components/pop/PopBottomNav.tsx new file mode 100644 index 00000000..f3fb86ae --- /dev/null +++ b/frontend/components/pop/PopBottomNav.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { Clock, ClipboardList } from "lucide-react"; + +export function PopBottomNav() { + const handleHistoryClick = () => { + console.log("작업이력 클릭"); + // TODO: 작업이력 페이지 이동 또는 모달 열기 + }; + + const handleRegisterClick = () => { + console.log("실적등록 클릭"); + // TODO: 실적등록 모달 열기 + }; + + return ( +
+ + +
+ ); +} + diff --git a/frontend/components/pop/PopEquipmentModal.tsx b/frontend/components/pop/PopEquipmentModal.tsx new file mode 100644 index 00000000..cfae902f --- /dev/null +++ b/frontend/components/pop/PopEquipmentModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React from "react"; +import { X } from "lucide-react"; +import { Equipment } from "./types"; + +interface PopEquipmentModalProps { + isOpen: boolean; + equipments: Equipment[]; + selectedEquipment: Equipment | null; + onSelect: (equipment: Equipment) => void; + onClose: () => void; +} + +export function PopEquipmentModal({ + isOpen, + equipments, + selectedEquipment, + onSelect, + onClose, +}: PopEquipmentModalProps) { + const [tempSelected, setTempSelected] = React.useState(selectedEquipment); + + React.useEffect(() => { + setTempSelected(selectedEquipment); + }, [selectedEquipment, isOpen]); + + const handleConfirm = () => { + if (tempSelected) { + onSelect(tempSelected); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

설비 선택

+ +
+ +
+
+ {equipments.map((equip) => ( +
setTempSelected(equip)} + > +
+
{equip.name}
+
{equip.processNames.join(", ")}
+
+ ))} +
+
+ +
+ + +
+
+
+ ); +} + diff --git a/frontend/components/pop/PopHeader.tsx b/frontend/components/pop/PopHeader.tsx new file mode 100644 index 00000000..b2266eef --- /dev/null +++ b/frontend/components/pop/PopHeader.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Moon, Sun } from "lucide-react"; +import { Equipment, Process, ProductionType } from "./types"; + +interface PopHeaderProps { + currentDateTime: Date; + productionType: ProductionType; + selectedEquipment: Equipment | null; + selectedProcess: Process | null; + showMyWorkOnly: boolean; + theme: "dark" | "light"; + onProductionTypeChange: (type: ProductionType) => void; + onEquipmentClick: () => void; + onProcessClick: () => void; + onMyWorkToggle: () => void; + onSearchClick: () => void; + onSettingsClick: () => void; + onThemeToggle: () => void; +} + +export function PopHeader({ + currentDateTime, + productionType, + selectedEquipment, + selectedProcess, + showMyWorkOnly, + theme, + onProductionTypeChange, + onEquipmentClick, + onProcessClick, + onMyWorkToggle, + onSearchClick, + onSettingsClick, + onThemeToggle, +}: PopHeaderProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const formatTime = (date: Date) => { + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; + }; + + return ( +
+ {/* 1행: 날짜/시간 + 테마 토글 + 작업지시/원자재 */} +
+
+ {mounted ? formatDate(currentDateTime) : "----.--.--"} + {mounted ? formatTime(currentDateTime) : "--:--"} +
+ + {/* 테마 토글 버튼 */} + + +
+ +
+ + +
+
+ + {/* 2행: 필터 버튼들 */} +
+ + + + +
+ + + +
+
+ ); +} + diff --git a/frontend/components/pop/PopProcessModal.tsx b/frontend/components/pop/PopProcessModal.tsx new file mode 100644 index 00000000..74f72c7e --- /dev/null +++ b/frontend/components/pop/PopProcessModal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import { X } from "lucide-react"; +import { Equipment, Process } from "./types"; + +interface PopProcessModalProps { + isOpen: boolean; + selectedEquipment: Equipment | null; + selectedProcess: Process | null; + processes: Process[]; + onSelect: (process: Process) => void; + onClose: () => void; +} + +export function PopProcessModal({ + isOpen, + selectedEquipment, + selectedProcess, + processes, + onSelect, + onClose, +}: PopProcessModalProps) { + const [tempSelected, setTempSelected] = React.useState(selectedProcess); + + React.useEffect(() => { + setTempSelected(selectedProcess); + }, [selectedProcess, isOpen]); + + const handleConfirm = () => { + if (tempSelected) { + onSelect(tempSelected); + onClose(); + } + }; + + if (!isOpen || !selectedEquipment) return null; + + // 선택된 설비의 공정만 필터링 + const availableProcesses = selectedEquipment.processIds.map((processId, index) => { + const process = processes.find((p) => p.id === processId); + return { + id: processId, + name: selectedEquipment.processNames[index], + code: process?.code || "", + }; + }); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

공정 선택

+ +
+ +
+
+ {availableProcesses.map((process) => ( +
setTempSelected(process as Process)} + > +
+
{process.name}
+
{process.code}
+
+ ))} +
+
+ +
+ + +
+
+
+ ); +} + diff --git a/frontend/components/pop/PopProductionPanel.tsx b/frontend/components/pop/PopProductionPanel.tsx new file mode 100644 index 00000000..ceb1d902 --- /dev/null +++ b/frontend/components/pop/PopProductionPanel.tsx @@ -0,0 +1,344 @@ +"use client"; + +import React from "react"; +import { X, Play, Square, ChevronRight } from "lucide-react"; +import { WorkOrder, WorkStep } from "./types"; + +interface PopProductionPanelProps { + isOpen: boolean; + workOrder: WorkOrder | null; + workSteps: WorkStep[]; + currentStepIndex: number; + currentDateTime: Date; + onStepChange: (index: number) => void; + onStepsUpdate: (steps: WorkStep[]) => void; + onClose: () => void; +} + +export function PopProductionPanel({ + isOpen, + workOrder, + workSteps, + currentStepIndex, + currentDateTime, + onStepChange, + onStepsUpdate, + onClose, +}: PopProductionPanelProps) { + if (!isOpen || !workOrder) return null; + + const currentStep = workSteps[currentStepIndex]; + + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const formatTime = (date: Date | null) => { + if (!date) return "--:--"; + const d = new Date(date); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + }; + + const handleStartStep = () => { + const newSteps = [...workSteps]; + newSteps[currentStepIndex] = { + ...newSteps[currentStepIndex], + status: "in-progress", + startTime: new Date(), + }; + onStepsUpdate(newSteps); + }; + + const handleEndStep = () => { + const newSteps = [...workSteps]; + newSteps[currentStepIndex] = { + ...newSteps[currentStepIndex], + endTime: new Date(), + }; + onStepsUpdate(newSteps); + }; + + const handleSaveAndNext = () => { + const newSteps = [...workSteps]; + const step = newSteps[currentStepIndex]; + + // 시간 자동 설정 + if (!step.startTime) step.startTime = new Date(); + if (!step.endTime) step.endTime = new Date(); + step.status = "completed"; + + onStepsUpdate(newSteps); + + // 다음 단계로 이동 + if (currentStepIndex < workSteps.length - 1) { + onStepChange(currentStepIndex + 1); + } + }; + + const renderStepForm = () => { + if (!currentStep) return null; + + const isCompleted = currentStep.status === "completed"; + + if (currentStep.type === "work" || currentStep.type === "record") { + return ( +
+

작업 내용 입력

+
+
+ + +
+
+ + +
+
+
+ +