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/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/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 0343f539..1f577777 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1978,15 +1978,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, }); // 기존 데이터 삭제 옵션 @@ -2004,7 +2010,15 @@ export async function multiTableSave( } // 메인 데이터도 서브 테이블에 저장 (옵션) - if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) { + // mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지) + logger.info(`saveMainAsFirst 옵션 확인:`, { + saveMainAsFirst: options?.saveMainAsFirst, + mainFieldMappings: options?.mainFieldMappings, + mainFieldMappingsLength: options?.mainFieldMappings?.length, + linkColumn, + mainDataKeys: Object.keys(mainData), + }); + if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; 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/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/services/adminService.ts b/backend-node/src/services/adminService.ts index 5ca6b392..1b9280db 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -412,9 +412,9 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { - // SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시 - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + if (userType === "SUPER_ADMIN") { + // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시 + logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`); authFilter = ""; unionFilter = ""; } else { diff --git a/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/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 100% rename from frontend/app/(main)/admin/screenMng/page.tsx rename to frontend/app/(main)/admin/screenMng/screenMngList/page.tsx 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 100% rename from frontend/app/(main)/admin/tableMng/page.tsx rename to frontend/app/(main)/admin/systemMng/tableMngList/page.tsx 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/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) {
)} + {/* WACE 관리자: 현재 관리 회사 표시 */} + {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && ( +
+
+ +
+

현재 관리 회사

+

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

+
+
+
+ )} + {/* Admin/User 모드 전환 버튼 (관리자만) */} {((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" || (user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" || (user as ExtendedUserInfo)?.userType === "admin") && ( -
+
+ {/* 관리자/사용자 메뉴 전환 */} + + {/* WACE 관리자 전용: 회사 선택 버튼 */} + {(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && ( + + )}
)} @@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) { onSave={saveProfile} onAlertClose={closeAlert} /> + + {/* 회사 전환 모달 (WACE 관리자 전용) */} + + + + 회사 선택 + + 관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다. + + +
+ setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} /> +
+
+
); } diff --git a/frontend/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/report/ReportListTable.tsx b/frontend/components/report/ReportListTable.tsx index f8ad96ad..0629da51 100644 --- a/frontend/components/report/ReportListTable.tsx +++ b/frontend/components/report/ReportListTable.tsx @@ -49,7 +49,7 @@ export function ReportListTable({ // 수정 const handleEdit = (reportId: string) => { - router.push(`/admin/report/designer/${reportId}`); + router.push(`/admin/screenMng/reportList/designer/${reportId}`); }; // 복사 diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index ccc3aa8a..da440abc 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { selectedComponentId, selectedComponentIds, selectComponent, + selectMultipleComponents, updateComponent, getQueryResult, snapValueToGrid, @@ -178,20 +179,192 @@ export function CanvasComponent({ component }: CanvasComponentProps) { margins, layoutConfig, currentPageId, + duplicateAtPosition, } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 }); const componentRef = useRef(null); + + // Alt+드래그 복제를 위한 상태 + const [isAltDuplicating, setIsAltDuplicating] = useState(false); + const duplicatedIdsRef = useRef([]); + // 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용) + const originalPositionsRef = useRef>(new Map()); + + // 인라인 편집 상태 + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + const textareaRef = useRef(null); const isSelected = selectedComponentId === component.id; const isMultiSelected = selectedComponentIds.includes(component.id); const isLocked = component.locked === true; const isGrouped = !!component.groupId; + // 표시할 값 결정 + const getDisplayValue = (): string => { + // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회 + if (component.queryId && component.fieldName) { + const queryResult = getQueryResult(component.queryId); + + // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시 + if (queryResult && queryResult.rows.length > 0) { + const firstRow = queryResult.rows[0]; + const value = firstRow[component.fieldName]; + + // 값이 있으면 문자열로 변환하여 반환 + if (value !== null && value !== undefined) { + return String(value); + } + } + + // 실행 결과가 없거나 값이 없으면 필드명 표시 + return `{${component.fieldName}}`; + } + + // 기본값이 있으면 기본값 표시 + if (component.defaultValue) { + return component.defaultValue; + } + + // 둘 다 없으면 타입에 따라 기본 텍스트 + return component.type === "text" ? "텍스트 입력" : "레이블 텍스트"; + }; + + // 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절 + const fitTextToContent = () => { + if (isLocked) return; + if (component.type !== "text" && component.type !== "label") return; + + const minWidth = 50; + const minHeight = 30; + + // 여백을 px로 변환 + const marginRightPx = margins.right * MM_TO_PX; + const marginBottomPx = margins.bottom * MM_TO_PX; + const canvasWidthPx = canvasWidth * MM_TO_PX; + const canvasHeightPx = canvasHeight * MM_TO_PX; + + // 최대 크기 (여백 고려) + const maxWidth = canvasWidthPx - marginRightPx - component.x; + const maxHeight = canvasHeightPx - marginBottomPx - component.y; + + const displayValue = getDisplayValue(); + const fontSize = component.fontSize || 14; + + // 줄바꿈으로 분리하여 각 줄의 너비 측정 + const lines = displayValue.split("\n"); + let maxLineWidth = 0; + + lines.forEach((line) => { + const measureEl = document.createElement("span"); + measureEl.style.position = "absolute"; + measureEl.style.visibility = "hidden"; + measureEl.style.whiteSpace = "nowrap"; + measureEl.style.fontSize = `${fontSize}px`; + measureEl.style.fontWeight = component.fontWeight || "normal"; + measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif"; + measureEl.textContent = line || " "; // 빈 줄은 공백으로 + document.body.appendChild(measureEl); + + const lineWidth = measureEl.getBoundingClientRect().width; + maxLineWidth = Math.max(maxLineWidth, lineWidth); + document.body.removeChild(measureEl); + }); + + // 컴포넌트 padding (p-2 = 8px * 2) + 여유분 + const horizontalPadding = 24; + const verticalPadding = 20; + + // 줄 높이 계산 (font-size * line-height 약 1.5) + const lineHeight = fontSize * 1.5; + const totalHeight = lines.length * lineHeight; + + const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth); + const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight); + + const newWidth = Math.max(minWidth, finalWidth); + const newHeight = Math.max(minHeight, finalHeight); + + // 크기 업데이트 + updateComponent(component.id, { + width: snapValueToGrid(newWidth), + height: snapValueToGrid(newHeight), + }); + }; + + // 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입) + const handleDoubleClick = (e: React.MouseEvent) => { + if (component.type !== "text" && component.type !== "label") return; + if (isLocked) return; // 잠긴 컴포넌트는 편집 불가 + + e.stopPropagation(); + + // 인라인 편집 모드 진입 + setEditValue(component.defaultValue || ""); + setIsEditing(true); + }; + + // 인라인 편집 시작 시 textarea에 포커스 + useEffect(() => { + if (isEditing && textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.select(); + } + }, [isEditing]); + + // 선택 해제 시 편집 모드 종료를 위한 ref + const editValueRef = useRef(editValue); + const isEditingRef = useRef(isEditing); + editValueRef.current = editValue; + isEditingRef.current = isEditing; + + // 선택 해제 시 편집 모드 종료 (저장 후 종료) + useEffect(() => { + if (!isSelected && !isMultiSelected && isEditingRef.current) { + // 현재 편집 값으로 저장 + if (editValueRef.current !== component.defaultValue) { + updateComponent(component.id, { defaultValue: editValueRef.current }); + } + setIsEditing(false); + } + }, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]); + + // 인라인 편집 저장 + const handleEditSave = () => { + if (!isEditing) return; + + updateComponent(component.id, { + defaultValue: editValue, + }); + setIsEditing(false); + }; + + // 인라인 편집 취소 + const handleEditCancel = () => { + setIsEditing(false); + setEditValue(""); + }; + + // 인라인 편집 키보드 핸들러 + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + handleEditCancel(); + } else if (e.key === "Enter" && !e.shiftKey) { + // Enter: 저장 (Shift+Enter는 줄바꿈) + e.preventDefault(); + handleEditSave(); + } + }; + // 드래그 시작 const handleMouseDown = (e: React.MouseEvent) => { + // 편집 모드에서는 드래그 비활성화 + if (isEditing) return; + if ((e.target as HTMLElement).classList.contains("resize-handle")) { return; } @@ -209,16 +382,83 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; + // Alt 키 감지 (복제 드래그) + const isAltPressed = e.altKey; - // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택 - if (isGrouped && !isMultiSelect) { - const groupMembers = components.filter((c) => c.groupId === component.groupId); - const groupMemberIds = groupMembers.map((c) => c.id); - // 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가 - selectComponent(groupMemberIds[0], false); - groupMemberIds.slice(1).forEach((id) => selectComponent(id, true)); - } else { - selectComponent(component.id, isMultiSelect); + // 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작) + const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id); + + if (!isPartOfMultiSelection) { + // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택 + if (isGrouped && !isMultiSelect) { + const groupMembers = components.filter((c) => c.groupId === component.groupId); + const groupMemberIds = groupMembers.map((c) => c.id); + // 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가 + selectComponent(groupMemberIds[0], false); + groupMemberIds.slice(1).forEach((id) => selectComponent(id, true)); + } else { + selectComponent(component.id, isMultiSelect); + } + } + + // Alt+드래그: 복제 모드 + if (isAltPressed) { + // 복제할 컴포넌트 ID 목록 결정 + let idsToClone: string[] = []; + + if (isPartOfMultiSelection) { + // 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제 + idsToClone = selectedComponentIds.filter((id) => { + const c = components.find((comp) => comp.id === id); + return c && !c.locked; + }); + } else if (isGrouped) { + // 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제 + idsToClone = components + .filter((c) => c.groupId === component.groupId && !c.locked) + .map((c) => c.id); + } else { + // 단일 컴포넌트 + idsToClone = [component.id]; + } + + if (idsToClone.length > 0) { + // 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용) + const positionsMap = new Map(); + idsToClone.forEach((id) => { + const comp = components.find((c) => c.id === id); + if (comp) { + positionsMap.set(id, { x: comp.x, y: comp.y }); + } + }); + + // 복제 생성 (오프셋 없이 원래 위치에) + const newIds = duplicateAtPosition(idsToClone, 0, 0); + if (newIds.length > 0) { + // 복제된 컴포넌트 ID와 원본 위치 매핑 + // newIds[i]는 idsToClone[i]에서 복제됨 + const dupPositionsMap = new Map(); + newIds.forEach((newId, index) => { + const originalId = idsToClone[index]; + const originalPos = positionsMap.get(originalId); + if (originalPos) { + dupPositionsMap.set(newId, originalPos); + } + }); + originalPositionsRef.current = dupPositionsMap; + + // 복제된 컴포넌트들을 선택하고 드래그 시작 + duplicatedIdsRef.current = newIds; + setIsAltDuplicating(true); + + // 복제된 컴포넌트들 선택 + if (newIds.length === 1) { + selectComponent(newIds[0], false); + } else { + selectMultipleComponents(newIds); + } + } + } } setIsDragging(true); @@ -284,14 +524,58 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const deltaX = snappedX - component.x; const deltaY = snappedY - component.y; + // Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동 + if (isAltDuplicating && duplicatedIdsRef.current.length > 0) { + // 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정) + duplicatedIdsRef.current.forEach((dupId) => { + const dupComp = components.find((c) => c.id === dupId); + const originalPos = originalPositionsRef.current.get(dupId); + + if (dupComp && originalPos) { + // 각 복제본의 원본 위치에서 delta만큼 이동 + const targetX = originalPos.x + deltaX; + const targetY = originalPos.y + deltaY; + + // 경계 체크 + const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width; + const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height; + + updateComponent(dupId, { + x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX), + y: Math.min(Math.max(marginTopPx, targetY), dupMaxY), + }); + } + }); + return; // 원본 컴포넌트는 이동하지 않음 + } + // 현재 컴포넌트 이동 updateComponent(component.id, { x: snappedX, y: snappedY, }); + // 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동 + if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) { + components.forEach((c) => { + // 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외 + if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) { + const newMultiX = c.x + deltaX; + const newMultiY = c.y + deltaY; + + // 경계 체크 + const multiMaxX = canvasWidthPx - marginRightPx - c.width; + const multiMaxY = canvasHeightPx - marginBottomPx - c.height; + + updateComponent(c.id, { + x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX), + y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY), + }); + } + }); + } // 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동 - if (isGrouped) { + else if (isGrouped) { components.forEach((c) => { if (c.groupId === component.groupId && c.id !== component.id) { const newGroupX = c.x + deltaX; @@ -369,6 +653,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); + // Alt 복제 상태 초기화 + setIsAltDuplicating(false); + duplicatedIdsRef.current = []; + originalPositionsRef.current = new Map(); // 가이드라인 초기화 clearAlignmentGuides(); }; @@ -383,6 +671,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { }, [ isDragging, isResizing, + isAltDuplicating, dragStart.x, dragStart.y, resizeStart.x, @@ -405,36 +694,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { canvasHeight, ]); - // 표시할 값 결정 - const getDisplayValue = (): string => { - // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회 - if (component.queryId && component.fieldName) { - const queryResult = getQueryResult(component.queryId); - - // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시 - if (queryResult && queryResult.rows.length > 0) { - const firstRow = queryResult.rows[0]; - const value = firstRow[component.fieldName]; - - // 값이 있으면 문자열로 변환하여 반환 - if (value !== null && value !== undefined) { - return String(value); - } - } - - // 실행 결과가 없거나 값이 없으면 필드명 표시 - return `{${component.fieldName}}`; - } - - // 기본값이 있으면 기본값 표시 - if (component.defaultValue) { - return component.defaultValue; - } - - // 둘 다 없으면 타입에 따라 기본 텍스트 - return component.type === "text" ? "텍스트 입력" : "레이블 텍스트"; - }; - // 컴포넌트 타입별 렌더링 const renderContent = () => { const displayValue = getDisplayValue(); @@ -443,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) { switch (component.type) { case "text": case "label": + // 인라인 편집 모드 + if (isEditing) { + return ( +