Compare commits

..

No commits in common. "48b3687e41ce4d27cf002f561b55ef3f1469c03f" and "da0a82a0ec898c00653188d2c9ef3c636213914d" have entirely different histories.

24 changed files with 181 additions and 5580 deletions

View File

@ -9,7 +9,6 @@ import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil"; import { EncryptUtil } from "../utils/encryptUtil";
import { FileSystemManager } from "../utils/fileSystemManager"; import { FileSystemManager } from "../utils/fileSystemManager";
import { validateBusinessNumber } from "../utils/businessNumberValidator"; import { validateBusinessNumber } from "../utils/businessNumberValidator";
import { MenuCopyService } from "../services/menuCopyService";
/** /**
* *
@ -3254,93 +3253,3 @@ export async function getTableSchema(
}); });
} }
} }
/**
*
* POST /api/admin/menus/:menuObjid/copy
*/
export async function copyMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const { targetCompanyCode } = req.body;
const userId = req.user!.userId;
const userCompanyCode = req.user!.companyCode;
const userType = req.user!.userType;
const isSuperAdmin = req.user!.isSuperAdmin;
logger.info(`
=== API ===
menuObjid: ${menuObjid}
targetCompanyCode: ${targetCompanyCode}
userId: ${userId}
userCompanyCode: ${userCompanyCode}
userType: ${userType}
isSuperAdmin: ${isSuperAdmin}
`);
// 권한 체크: 최고 관리자만 가능
if (!isSuperAdmin && userType !== "SUPER_ADMIN") {
logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`);
res.status(403).json({
success: false,
message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다",
error: {
code: "FORBIDDEN",
details: "Only super admin can copy menus",
},
});
return;
}
// 필수 파라미터 검증
if (!menuObjid || !targetCompanyCode) {
res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다",
error: {
code: "MISSING_PARAMETERS",
details: "menuObjid and targetCompanyCode are required",
},
});
return;
}
// 화면명 변환 설정 (선택사항)
const screenNameConfig = req.body.screenNameConfig
? {
removeText: req.body.screenNameConfig.removeText,
addPrefix: req.body.screenNameConfig.addPrefix,
}
: undefined;
// 메뉴 복사 실행
const menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10),
targetCompanyCode,
userId,
screenNameConfig
);
logger.info("✅ 메뉴 복사 API 성공");
res.json({
success: true,
message: "메뉴 복사 완료",
data: result,
});
} catch (error: any) {
logger.error("❌ 메뉴 복사 API 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 복사 중 오류가 발생했습니다",
error: {
code: "MENU_COPY_ERROR",
details: error.message || "Unknown error",
},
});
}
}

View File

@ -8,7 +8,6 @@ import {
deleteMenu, // 메뉴 삭제 deleteMenu, // 메뉴 삭제
deleteMenusBatch, // 메뉴 일괄 삭제 deleteMenusBatch, // 메뉴 일괄 삭제
toggleMenuStatus, // 메뉴 상태 토글 toggleMenuStatus, // 메뉴 상태 토글
copyMenu, // 메뉴 복사
getUserList, getUserList,
getUserInfo, // 사용자 상세 조회 getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회 getUserHistory, // 사용자 변경이력 조회
@ -40,7 +39,6 @@ router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus); router.get("/user-menus", getUserMenus);
router.get("/menus/:menuId", getMenuInfo); router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가 router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
router.put("/menus/:menuId", updateMenu); // 메뉴 수정 router.put("/menus/:menuId", updateMenu); // 메뉴 수정
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글 router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!) router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)

File diff suppressed because it is too large Load Diff

View File

@ -300,9 +300,10 @@ class NumberingRuleService {
FROM numbering_rules FROM numbering_rules
WHERE WHERE
scope_type = 'global' scope_type = 'global'
OR scope_type = 'table'
OR (scope_type = 'menu' AND menu_objid = ANY($1)) OR (scope_type = 'menu' AND menu_objid = ANY($1))
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- OR (scope_type = 'table' AND menu_objid = ANY($1)) -- 임시: table menu_objid
OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( ) OR (scope_type = 'table' AND menu_objid IS NULL) -- 임시: (menu_objid NULL)
ORDER BY ORDER BY
CASE CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
@ -312,9 +313,9 @@ class NumberingRuleService {
created_at DESC created_at DESC
`; `;
params = [siblingObjids]; params = [siblingObjids];
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
} else { } else {
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -335,9 +336,10 @@ class NumberingRuleService {
WHERE company_code = $1 WHERE company_code = $1
AND ( AND (
scope_type = 'global' scope_type = 'global'
OR scope_type = 'table'
OR (scope_type = 'menu' AND menu_objid = ANY($2)) OR (scope_type = 'menu' AND menu_objid = ANY($2))
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- OR (scope_type = 'table' AND menu_objid = ANY($2)) -- 임시: table menu_objid
OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( ) OR (scope_type = 'table' AND menu_objid IS NULL) -- 임시: (menu_objid NULL)
) )
ORDER BY ORDER BY
CASE CASE
@ -348,7 +350,7 @@ class NumberingRuleService {
created_at DESC created_at DESC
`; `;
params = [companyCode, siblingObjids]; params = [companyCode, siblingObjids];
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
} }
logger.info("🔍 채번 규칙 쿼리 실행", { logger.info("🔍 채번 규칙 쿼리 실행", {

View File

@ -1,184 +0,0 @@
# 마이그레이션 1003: source_menu_objid 추가
## 📋 개요
메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다.
## 🎯 목적
### 이전 방식의 문제점
- 메뉴 이름으로만 기존 복사본 판단
- 같은 이름의 다른 메뉴도 삭제될 위험
- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가
### 개선 후
- 원본 메뉴 ID로 정확히 추적
- 같은 원본에서 복사된 메뉴만 덮어쓰기
- 수동 메뉴와 복사 메뉴 명확히 구분
## 🗄️ 스키마 변경
### 추가되는 컬럼
```sql
ALTER TABLE menu_info
ADD COLUMN source_menu_objid BIGINT;
```
### 인덱스
```sql
-- 단일 인덱스
CREATE INDEX idx_menu_info_source_menu_objid
ON menu_info(source_menu_objid);
-- 복합 인덱스 (회사별 검색 최적화)
CREATE INDEX idx_menu_info_source_company
ON menu_info(source_menu_objid, company_code);
```
## 📊 데이터 구조
### 복사된 메뉴의 source_menu_objid 값
| 메뉴 레벨 | source_menu_objid | 설명 |
|-----------|-------------------|------|
| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 |
| 하위 메뉴 | NULL | 최상위 메뉴만 추적 |
| 수동 생성 메뉴 | NULL | 복사가 아님 |
### 예시
#### 원본 (COMPANY_7)
```
- 사용자 (objid: 1762407678882)
└─ 영업관리 (objid: 1762421877772)
└─ 거래처관리 (objid: 1762421920304)
```
#### 복사본 (COMPANY_11)
```
- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적
└─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL)
└─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL)
```
## 🚀 실행 방법
### 로컬 PostgreSQL
```bash
psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
```
### Docker 환경
```bash
# 백엔드 컨테이너를 통해 실행
docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql
```
### DBeaver / pgAdmin
1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기
2. 전체 스크립트 실행
## ✅ 확인 방법
### 1. 컬럼 추가 확인
```sql
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'menu_info'
AND column_name = 'source_menu_objid';
```
**예상 결과**:
```
column_name | data_type | is_nullable
-------------------|-----------|-------------
source_menu_objid | bigint | YES
```
### 2. 인덱스 생성 확인
```sql
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'menu_info'
AND indexname LIKE '%source%';
```
**예상 결과**:
```
indexname | indexdef
---------------------------------|----------------------------------
idx_menu_info_source_menu_objid | CREATE INDEX ...
idx_menu_info_source_company | CREATE INDEX ...
```
### 3. 기존 데이터 확인
```sql
-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터)
SELECT
COUNT(*) as total,
COUNT(source_menu_objid) as with_source
FROM menu_info;
```
**예상 결과**:
```
total | with_source
------|-------------
114 | 0
```
## 🔄 롤백 (필요 시)
```sql
-- 인덱스 삭제
DROP INDEX IF EXISTS idx_menu_info_source_menu_objid;
DROP INDEX IF EXISTS idx_menu_info_source_company;
-- 컬럼 삭제
ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid;
```
## 📝 주의사항
1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다.
2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다.
3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다.
## 🧪 테스트 시나리오
### 1. 첫 복사 (source_menu_objid 설정)
```
원본: 사용자 (objid: 1762407678882, COMPANY_7)
복사: 사용자 (objid: 1763688215729, COMPANY_11)
source_menu_objid: 1762407678882 ✅
```
### 2. 재복사 (정확한 덮어쓰기)
```
복사 전 조회:
SELECT objid FROM menu_info
WHERE source_menu_objid = 1762407678882
AND company_code = 'COMPANY_11'
→ 1763688215729 발견
동작:
1. objid=1763688215729의 메뉴 트리 전체 삭제
2. 새로 복사 (source_menu_objid: 1762407678882)
```
### 3. 다른 메뉴는 영향 없음
```
수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11)
source_menu_objid: NULL ✅
"사용자" 메뉴 재복사 시:
→ 관리자 메뉴는 그대로 유지 ✅
```
## 📚 관련 파일
- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql`
- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts`
- `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기
- `copyMenus()`: 복사 시 source_menu_objid 저장

View File

@ -1,146 +0,0 @@
# 마이그레이션 1003 실행 가이드
## ❌ 현재 에러
```
column "source_menu_objid" does not exist
```
**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음
## ✅ 해결 방법
### 방법 1: psql 직접 실행 (권장)
```bash
# 1. PostgreSQL 접속 정보 확인
# - Host: localhost (또는 실제 DB 호스트)
# - Port: 5432 (기본값)
# - Database: ilshin
# - User: postgres
# 2. 마이그레이션 실행
cd /Users/kimjuseok/ERP-node
psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
# 또는 대화형으로
psql -h localhost -U postgres -d ilshin
# 그 다음 파일 내용 붙여넣기
```
### 방법 2: DBeaver / pgAdmin에서 실행
1. DBeaver 또는 pgAdmin 실행
2. `ilshin` 데이터베이스 연결
3. SQL 편집기 열기
4. 아래 SQL 복사하여 실행:
```sql
-- source_menu_objid 컬럼 추가
ALTER TABLE menu_info
ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
-- 인덱스 생성 (검색 성능 향상)
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid
ON menu_info(source_menu_objid);
-- 복합 인덱스: 회사별 원본 메뉴 검색
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company
ON menu_info(source_menu_objid, company_code);
-- 컬럼 설명 추가
COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)';
-- 확인
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'menu_info'
AND column_name = 'source_menu_objid';
```
### 방법 3: Docker를 통한 실행
Docker Compose 설정 확인 후:
```bash
# Docker Compose에 DB 서비스가 있는 경우
docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql
# 또는 SQL을 직접 실행
docker-compose exec db psql -U postgres -d ilshin -c "
ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid);
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code);
"
```
## ✅ 실행 후 확인
### 1. 컬럼이 추가되었는지 확인
```sql
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'menu_info'
AND column_name = 'source_menu_objid';
```
**예상 결과**:
```
column_name | data_type | is_nullable
-------------------|-----------|-------------
source_menu_objid | bigint | YES
```
### 2. 인덱스 확인
```sql
SELECT indexname
FROM pg_indexes
WHERE tablename = 'menu_info'
AND indexname LIKE '%source%';
```
**예상 결과**:
```
indexname
---------------------------------
idx_menu_info_source_menu_objid
idx_menu_info_source_company
```
### 3. 메뉴 복사 재시도
마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요.
## 🔍 DB 접속 정보 찾기
### 환경 변수 확인
```bash
# .env 파일 확인
cat backend-node/.env | grep DB
# Docker Compose 확인
cat docker-compose*.yml | grep -A 10 postgres
```
### 일반적인 접속 정보
- **Host**: localhost 또는 127.0.0.1
- **Port**: 5432 (기본값)
- **Database**: ilshin
- **User**: postgres
- **Password**: (환경 설정 파일에서 확인)
## ⚠️ 주의사항
1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장
2. **권한 확인**: ALTER TABLE 권한이 필요합니다
3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다
## 📞 문제 해결
### "permission denied" 에러
→ postgres 사용자 또는 superuser 권한으로 실행 필요
### "relation does not exist" 에러
→ 올바른 데이터베이스(ilshin)에 접속했는지 확인
### "already exists" 에러
→ 이미 실행됨. 무시하고 진행 가능

View File

@ -1,126 +0,0 @@
# COMPANY_11 테스트 데이터 정리 가이드
## 📋 개요
메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다.
## ⚠️ 중요 사항
- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`)
- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드
- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능
- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT
## 🚀 실행 방법
### 방법 1: Docker 컨테이너에서 직접 실행 (권장)
```bash
# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨)
cd /Users/kimjuseok/ERP-node
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql
# 2. 실제 삭제 실행
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
```
### 방법 2: DBeaver 또는 pgAdmin에서 실행
1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기
2. 전체 스크립트 실행 (롤백되어 안전)
3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행
### 방법 3: psql 직접 접속
```bash
# 1. 컨테이너 접속
docker exec -it erp-node-db-1 psql -U postgres -d ilshin
# 2. SQL 복사 붙여넣기
# (cleanup_company_11_execute.sql 내용 복사)
```
## 📊 삭제 대상
| 항목 | 테이블명 | 삭제 여부 |
|------|----------|-----------|
| 메뉴 | `menu_info` | ✅ 삭제 |
| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 |
| 화면 정의 | `screen_definitions` | ✅ 삭제 |
| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 |
| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 |
| 플로우 정의 | `flow_definition` | ✅ 삭제 |
| 플로우 스텝 | `flow_step` | ✅ 삭제 |
| 플로우 연결 | `flow_step_connection` | ✅ 삭제 |
| 코드 카테고리 | `code_category` | ✅ 삭제 |
| 코드 정보 | `code_info` | ✅ 삭제 |
| **권한 그룹** | `authority_master` | ❌ **보존** |
| **권한 멤버** | `authority_sub_user` | ❌ **보존** |
| **사용자** | `user_info` | ❌ **보존** |
## 🔍 삭제 순서 (외래키 제약 고려)
```
1. screen_layouts (화면 레이아웃)
2. screen_menu_assignments (화면-메뉴 할당)
3. screen_definitions (화면 정의)
4. rel_menu_auth (메뉴 권한)
5. menu_info (메뉴)
6. flow_step (플로우 스텝)
7. flow_step_connection (플로우 연결)
8. flow_definition (플로우 정의)
9. code_info (코드 정보)
10. code_category (코드 카테고리)
```
## ✅ 실행 후 확인
스크립트 실행 후 다음과 같이 표시됩니다:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 삭제 완료!
남은 데이터:
- 메뉴: 0 개
- 화면: 0 개
- 권한 그룹: 1 개 (보존됨)
- 사용자: 1 개 (보존됨)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✨ 정리 완료! 메뉴 복사 테스트 준비됨
```
## 🧪 테스트 시나리오
1. **데이터 정리**
```bash
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
```
2. **메뉴 복사 실행**
- 프론트엔드에서 원본 메뉴 선택
- "복사" 버튼 클릭
- 대상 회사: COMPANY_11 선택
- 복사 실행
3. **복사 결과 확인**
- COMPANY_11 사용자(copy)로 로그인
- 사용자 메뉴에 복사된 메뉴 표시 확인
- 버튼 클릭 시 모달 화면 정상 열림 확인
- 플로우 기능 정상 작동 확인
## 🔄 재테스트
재테스트가 필요하면 다시 정리 스크립트를 실행하세요:
```bash
# 빠른 재테스트
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
```
## 📝 참고
- **백업**: 중요한 데이터가 있다면 먼저 백업하세요
- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다
- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다

File diff suppressed because it is too large Load Diff

View File

@ -1,344 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { menuApi, MenuCopyResult } from "@/lib/api/menu";
import { apiClient } from "@/lib/api/client";
interface MenuCopyDialogProps {
menuObjid: number | null;
menuName: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCopyComplete?: () => void;
}
interface Company {
company_code: string;
company_name: string;
}
export function MenuCopyDialog({
menuObjid,
menuName,
open,
onOpenChange,
onCopyComplete,
}: MenuCopyDialogProps) {
const [targetCompanyCode, setTargetCompanyCode] = useState("");
const [companies, setCompanies] = useState<Company[]>([]);
const [copying, setCopying] = useState(false);
const [result, setResult] = useState<MenuCopyResult | null>(null);
const [loadingCompanies, setLoadingCompanies] = useState(false);
// 화면명 일괄 변경 설정
const [useBulkRename, setUseBulkRename] = useState(false);
const [removeText, setRemoveText] = useState("");
const [addPrefix, setAddPrefix] = useState("");
// 회사 목록 로드
useEffect(() => {
if (open) {
loadCompanies();
// 다이얼로그가 열릴 때마다 초기화
setTargetCompanyCode("");
setResult(null);
setUseBulkRename(false);
setRemoveText("");
setAddPrefix("");
}
}, [open]);
const loadCompanies = async () => {
try {
setLoadingCompanies(true);
const response = await apiClient.get("/admin/companies/db");
if (response.data.success && response.data.data) {
// 최고 관리자(*) 회사 제외
const filteredCompanies = response.data.data.filter(
(company: Company) => company.company_code !== "*"
);
setCompanies(filteredCompanies);
}
} catch (error) {
console.error("회사 목록 조회 실패:", error);
toast.error("회사 목록을 불러올 수 없습니다");
} finally {
setLoadingCompanies(false);
}
};
const handleCopy = async () => {
if (!menuObjid) {
toast.error("메뉴를 선택해주세요");
return;
}
if (!targetCompanyCode) {
toast.error("대상 회사를 선택해주세요");
return;
}
setCopying(true);
setResult(null);
try {
// 화면명 변환 설정 (사용 중일 때만 전달)
const screenNameConfig =
useBulkRename && (removeText.trim() || addPrefix.trim())
? {
removeText: removeText.trim() || undefined,
addPrefix: addPrefix.trim() || undefined,
}
: undefined;
const response = await menuApi.copyMenu(
menuObjid,
targetCompanyCode,
screenNameConfig
);
if (response.success && response.data) {
setResult(response.data);
toast.success("메뉴 복사 완료!");
// 경고 메시지 표시
if (response.data.warnings && response.data.warnings.length > 0) {
response.data.warnings.forEach((warning) => {
toast.warning(warning);
});
}
// 복사 완료 콜백
if (onCopyComplete) {
onCopyComplete();
}
} else {
toast.error(response.message || "메뉴 복사 실패");
}
} catch (error: any) {
console.error("메뉴 복사 오류:", error);
toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다");
} finally {
setCopying(false);
}
};
const handleClose = () => {
if (!copying) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{menuName}" .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 회사 선택 */}
{!result && (
<div>
<Label htmlFor="company" className="text-xs sm:text-sm">
*
</Label>
<Select
value={targetCompanyCode}
onValueChange={setTargetCompanyCode}
disabled={copying || loadingCompanies}
>
<SelectTrigger
id="company"
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{loadingCompanies ? (
<div className="flex items-center justify-center p-2 text-xs text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</div>
) : companies.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
</div>
) : (
companies.map((company) => (
<SelectItem
key={company.company_code}
value={company.company_code}
className="text-xs sm:text-sm"
>
{company.company_name} ({company.company_code})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{/* 화면명 일괄 변경 설정 */}
{!result && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
id="useBulkRename"
checked={useBulkRename}
onCheckedChange={(checked) => setUseBulkRename(checked as boolean)}
disabled={copying}
/>
<Label
htmlFor="useBulkRename"
className="text-xs sm:text-sm font-medium cursor-pointer"
>
</Label>
</div>
{useBulkRename && (
<div className="space-y-3 pl-6 border-l-2">
<div>
<Label htmlFor="removeText" className="text-xs sm:text-sm">
</Label>
<Input
id="removeText"
value={removeText}
onChange={(e) => setRemoveText(e.target.value)}
placeholder="예: 탑씰"
disabled={copying}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
(: "탑씰 회사정보" "회사정보")
</p>
</div>
<div>
<Label htmlFor="addPrefix" className="text-xs sm:text-sm">
</Label>
<Input
id="addPrefix"
value={addPrefix}
onChange={(e) => setAddPrefix(e.target.value)}
placeholder="예: 한신"
disabled={copying}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
(: "회사정보" "한신 회사정보")
</p>
</div>
</div>
)}
</div>
)}
{/* 복사 항목 안내 */}
{!result && (
<div className="rounded-md border p-3 text-xs">
<p className="font-medium mb-2"> :</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li> ( )</li>
<li> + (, )</li>
<li> (, )</li>
<li> + </li>
<li> + </li>
</ul>
<p className="mt-2 text-warning">
.
</p>
</div>
)}
{/* 복사 결과 */}
{result && (
<div className="rounded-md border border-success bg-success/10 p-3 text-xs space-y-2">
<p className="font-medium text-success"> !</p>
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedMenus}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedScreens}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedFlows}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedCategories}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedCodes}</span>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleClose}
disabled={copying}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{result ? "닫기" : "취소"}
</Button>
{!result && (
<Button
onClick={handleCopy}
disabled={copying || !targetCompanyCode || loadingCompanies}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{copying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"복사 시작"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -5,7 +5,6 @@ import { menuApi } from "@/lib/api/menu";
import type { MenuItem } from "@/lib/api/menu"; import type { MenuItem } from "@/lib/api/menu";
import { MenuTable } from "./MenuTable"; import { MenuTable } from "./MenuTable";
import { MenuFormModal } from "./MenuFormModal"; import { MenuFormModal } from "./MenuFormModal";
import { MenuCopyDialog } from "./MenuCopyDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
@ -26,21 +25,17 @@ import { useMenu } from "@/contexts/MenuContext";
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang"; import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
type MenuType = "admin" | "user"; type MenuType = "admin" | "user";
export const MenuManagement: React.FC = () => { export const MenuManagement: React.FC = () => {
const { adminMenus, userMenus, refreshMenus } = useMenu(); const { adminMenus, userMenus, refreshMenus } = useMenu();
const { user } = useAuth(); // 현재 사용자 정보 가져오기
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin"); const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [formModalOpen, setFormModalOpen] = useState(false); const [formModalOpen, setFormModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [selectedMenuId, setSelectedMenuId] = useState<string>(""); const [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set()); const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시) // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
@ -51,9 +46,6 @@ export const MenuManagement: React.FC = () => {
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용 // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
const { userLang } = useMultiLang({ companyCode: "*" }); const { userLang } = useMultiLang({ companyCode: "*" });
// SUPER_ADMIN 여부 확인
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
// 다국어 텍스트 상태 // 다국어 텍스트 상태
const [uiTexts, setUiTexts] = useState<Record<string, string>>({}); const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
const [uiTextsLoading, setUiTextsLoading] = useState(false); const [uiTextsLoading, setUiTextsLoading] = useState(false);
@ -757,18 +749,6 @@ export const MenuManagement: React.FC = () => {
} }
}; };
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) => { const handleToggleStatus = async (menuId: string) => {
try { try {
const response = await menuApi.toggleMenuStatus(menuId); const response = await menuApi.toggleMenuStatus(menuId);
@ -1082,7 +1062,6 @@ export const MenuManagement: React.FC = () => {
title="" title=""
onAddMenu={handleAddMenu} onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu} onEditMenu={handleEditMenu}
onCopyMenu={handleCopyMenu}
onToggleStatus={handleToggleStatus} onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus} selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange} onMenuSelectionChange={handleMenuSelectionChange}
@ -1090,7 +1069,6 @@ export const MenuManagement: React.FC = () => {
expandedMenus={expandedMenus} expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
uiTexts={uiTexts} uiTexts={uiTexts}
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
/> />
</div> </div>
</div> </div>
@ -1123,14 +1101,6 @@ export const MenuManagement: React.FC = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<MenuCopyDialog
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
menuName={selectedMenuName}
open={copyDialogOpen}
onOpenChange={setCopyDialogOpen}
onCopyComplete={handleCopyComplete}
/>
</LoadingOverlay> </LoadingOverlay>
); );
}; };

View File

@ -14,7 +14,6 @@ interface MenuTableProps {
title: string; title: string;
onAddMenu: (parentId: string, menuType: string, level: number) => void; onAddMenu: (parentId: string, menuType: string, level: number) => void;
onEditMenu: (menuId: string) => void; onEditMenu: (menuId: string) => void;
onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가
onToggleStatus: (menuId: string) => void; onToggleStatus: (menuId: string) => void;
selectedMenus: Set<string>; selectedMenus: Set<string>;
onMenuSelectionChange: (menuId: string, checked: boolean) => void; onMenuSelectionChange: (menuId: string, checked: boolean) => void;
@ -23,7 +22,6 @@ interface MenuTableProps {
onToggleExpand: (menuId: string) => void; onToggleExpand: (menuId: string) => void;
// 다국어 텍스트 props 추가 // 다국어 텍스트 props 추가
uiTexts: Record<string, string>; uiTexts: Record<string, string>;
isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가
} }
export const MenuTable: React.FC<MenuTableProps> = ({ export const MenuTable: React.FC<MenuTableProps> = ({
@ -31,7 +29,6 @@ export const MenuTable: React.FC<MenuTableProps> = ({
title, title,
onAddMenu, onAddMenu,
onEditMenu, onEditMenu,
onCopyMenu,
onToggleStatus, onToggleStatus,
selectedMenus, selectedMenus,
onMenuSelectionChange, onMenuSelectionChange,
@ -39,7 +36,6 @@ export const MenuTable: React.FC<MenuTableProps> = ({
expandedMenus, expandedMenus,
onToggleExpand, onToggleExpand,
uiTexts, uiTexts,
isSuperAdmin = false, // 기본값 false
}) => { }) => {
// 다국어 텍스트 가져오기 함수 // 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => { const getText = (key: string, fallback?: string): string => {
@ -285,26 +281,14 @@ export const MenuTable: React.FC<MenuTableProps> = ({
<TableCell className="h-16"> <TableCell className="h-16">
<div className="flex flex-nowrap gap-1"> <div className="flex flex-nowrap gap-1">
{lev === 1 && ( {lev === 1 && (
<> <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" className="min-w-[40px] px-1 py-1 text-xs"
className="min-w-[40px] px-1 py-1 text-xs" onClick={() => onAddMenu(objid, menuType, lev)}
onClick={() => onAddMenu(objid, menuType, lev)} >
> {getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)} </Button>
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
>
</Button>
)}
</>
)} )}
{lev === 2 && ( {lev === 2 && (
<> <>
@ -324,39 +308,17 @@ export const MenuTable: React.FC<MenuTableProps> = ({
> >
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)} {getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
</Button> </Button>
{isSuperAdmin && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
>
</Button>
)}
</> </>
)} )}
{lev > 2 && ( {lev > 2 && (
<> <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" className="min-w-[40px] px-1 py-1 text-xs"
className="min-w-[40px] px-1 py-1 text-xs" onClick={() => onEditMenu(objid)}
onClick={() => onEditMenu(objid)} >
> {getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)} </Button>
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
>
</Button>
)}
</>
)} )}
</div> </div>
</TableCell> </TableCell>

View File

@ -119,25 +119,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const [localHeight, setLocalHeight] = useState<string>(""); const [localHeight, setLocalHeight] = useState<string>("");
const [localWidth, setLocalWidth] = useState<string>(""); const [localWidth, setLocalWidth] = useState<string>("");
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
// 🆕 전체 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("전체 테이블 목록 로드 실패:", error);
}
};
loadAllTables();
}, []);
// 새로운 컴포넌트 시스템의 webType 동기화 // 새로운 컴포넌트 시스템의 webType 동기화
useEffect(() => { useEffect(() => {
if (selectedComponent?.type === "component") { if (selectedComponent?.type === "component") {
@ -298,18 +279,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}; };
const handleConfigChange = (newConfig: any) => { const handleConfigChange = (newConfig: any) => {
// 기존 config와 병합하여 다른 속성 유지 onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
const currentConfig = selectedComponent.componentConfig?.config || {};
const mergedConfig = { ...currentConfig, ...newConfig };
onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig);
}; };
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId = const componentId =
selectedComponent.componentType || // ⭐ section-card 등 selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id || selectedComponent.componentConfig?.id;
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
if (componentId) { if (componentId) {
const definition = ComponentRegistry.getComponent(componentId); const definition = ComponentRegistry.getComponent(componentId);
@ -341,14 +318,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Settings className="h-4 w-4 text-primary" /> <Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3> <h3 className="text-sm font-semibold">{definition.name} </h3>
</div> </div>
<ConfigPanelComponent <ConfigPanelComponent config={config} onChange={handleConfigChange} />
config={config}
onChange={handleConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</div> </div>
); );
}; };
@ -1024,16 +994,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
); );
} }
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
const configPanelContent = renderComponentConfigPanel();
if (configPanelContent) {
return configPanelContent;
}
}
// 현재 웹타입의 기본 입력 타입 추출 // 현재 웹타입의 기본 입력 타입 추출
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null; const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;

View File

@ -26,7 +26,6 @@ interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백 onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
screenId?: number; // 화면 ID 추가
} }
// 필터 타입별 연산자 // 필터 타입별 연산자
@ -70,7 +69,7 @@ interface ColumnFilterConfig {
selectOptions?: Array<{ label: string; value: string }>; selectOptions?: Array<{ label: string; value: string }>;
} }
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => { export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
const { getTable, selectedTableId } = useTableOptions(); const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined; const table = selectedTableId ? getTable(selectedTableId) : undefined;
@ -80,10 +79,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
// localStorage에서 저장된 필터 설정 불러오기 // localStorage에서 저장된 필터 설정 불러오기
useEffect(() => { useEffect(() => {
if (table?.columns && table?.tableName) { if (table?.columns && table?.tableName) {
// 화면별로 독립적인 필터 설정 저장 const storageKey = `table_filters_${table.tableName}`;
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey); const savedFilters = localStorage.getItem(storageKey);
let filters: ColumnFilterConfig[]; let filters: ColumnFilterConfig[];
@ -196,11 +192,9 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
width: cf.width || 200, // 너비 포함 (기본 200px) width: cf.width || 200, // 너비 포함 (기본 200px)
})); }));
// localStorage에 저장 (화면별로 독립적) // localStorage에 저장
if (table?.tableName) { if (table?.tableName) {
const storageKey = screenId const storageKey = `table_filters_${table.tableName}`;
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters)); localStorage.setItem(storageKey, JSON.stringify(columnFilters));
} }
@ -222,11 +216,9 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
setColumnFilters(clearedFilters); setColumnFilters(clearedFilters);
setSelectAll(false); setSelectAll(false);
// localStorage에서 제거 (화면별로 독립적) // localStorage에서 제거
if (table?.tableName) { if (table?.tableName) {
const storageKey = screenId const storageKey = `table_filters_${table.tableName}`;
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
} }

View File

@ -162,47 +162,4 @@ export const menuApi = {
throw error; throw error;
} }
}, },
// 메뉴 복사
copyMenu: async (
menuObjid: number,
targetCompanyCode: string,
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
}
): Promise<ApiResponse<MenuCopyResult>> => {
try {
const response = await apiClient.post(
`/admin/menus/${menuObjid}/copy`,
{
targetCompanyCode,
screenNameConfig
}
);
return response.data;
} catch (error: any) {
console.error("❌ 메뉴 복사 실패:", error);
return {
success: false,
message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다",
errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR",
};
}
},
}; };
/**
*
*/
export interface MenuCopyResult {
copiedMenus: number;
copiedScreens: number;
copiedFlows: number;
copiedCategories: number;
copiedCodes: number;
menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
warnings?: string[];
}

View File

@ -150,10 +150,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const columnName = (component as any).columnName; const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) if ((inputType === "category" || webType === "category") && tableName && columnName) {
if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") {
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
try { try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id; const fieldName = columnName || component.id;
@ -216,16 +213,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. 새 컴포넌트 시스템에서 먼저 조회 // 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType); const newComponent = ComponentRegistry.getComponent(componentType);
// 🔍 디버깅: select-basic 조회 결과 확인
if (componentType === "select-basic") {
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
componentType,
found: !!newComponent,
componentId: component.id,
componentConfig: component.componentConfig,
});
}
if (newComponent) { if (newComponent) {
// 새 컴포넌트 시스템으로 렌더링 // 새 컴포넌트 시스템으로 렌더링
try { try {

View File

@ -50,47 +50,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
menuObjid, // 🆕 메뉴 OBJID menuObjid, // 🆕 메뉴 OBJID
...props ...props
}) => { }) => {
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
componentId: component.id,
componentType: (component as any).componentType,
columnName: (component as any).columnName,
"props.multiple": (props as any).multiple,
"componentConfig.multiple": componentConfig?.multiple,
});
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
const config = (props as any).webTypeConfig || componentConfig || {}; const config = (props as any).webTypeConfig || componentConfig || {};
// 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위
const isMultiple = (props as any).multiple ?? config?.multiple ?? false;
// 🔍 디버깅: config 및 multiple 확인
useEffect(() => {
console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 ==========");
console.log(" 컴포넌트 ID:", component.id);
console.log(" 최종 isMultiple 값:", isMultiple);
console.log(" ----------------------------------------");
console.log(" props.multiple:", (props as any).multiple);
console.log(" config.multiple:", config?.multiple);
console.log(" componentConfig.multiple:", componentConfig?.multiple);
console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple);
console.log(" ----------------------------------------");
console.log(" config 전체:", config);
console.log(" componentConfig 전체:", componentConfig);
console.log(" component.componentConfig 전체:", component.componentConfig);
console.log(" =======================================");
// 다중선택이 활성화되었는지 알림
if (isMultiple) {
console.log("✅ 다중선택 모드 활성화됨!");
} else {
console.log("❌ 단일선택 모드 (다중선택 비활성화)");
}
}, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]);
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식) // webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
const webType = component.componentConfig?.webType || "select"; const webType = component.componentConfig?.webType || "select";
@ -98,14 +62,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
const [selectedLabel, setSelectedLabel] = useState(""); const [selectedLabel, setSelectedLabel] = useState("");
// multiselect의 경우 배열로 관리 (콤마 구분자로 파싱) // multiselect의 경우 배열로 관리
const [selectedValues, setSelectedValues] = useState<string[]>(() => { const [selectedValues, setSelectedValues] = useState<string[]>([]);
const initialValue = externalValue || config?.value || "";
if (isMultiple && typeof initialValue === "string" && initialValue) {
return initialValue.split(",").map(v => v.trim()).filter(v => v);
}
return [];
});
// autocomplete의 경우 검색어 관리 // autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -138,58 +96,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
isFetching, isFetching,
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid); } = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
// 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
useEffect(() => {
if (webType === "category" && component.tableName && component.columnName) {
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
tableName: component.tableName,
columnName: component.columnName,
webType,
});
setIsLoadingCategories(true);
import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => {
getCategoryValues(component.tableName!, component.columnName!)
.then((response) => {
console.log("🔍 [SelectBasic] 카테고리 API 응답:", response);
if (response.success && response.data) {
console.log("🔍 [SelectBasic] 원본 데이터 샘플:", {
firstItem: response.data[0],
keys: response.data[0] ? Object.keys(response.data[0]) : [],
});
const activeValues = response.data.filter((v) => v.isActive !== false);
const options = activeValues.map((v) => ({
value: v.valueCode,
label: v.valueLabel || v.valueCode,
}));
console.log("✅ [SelectBasic] 카테고리 옵션 설정:", {
activeValuesCount: activeValues.length,
options,
sampleOption: options[0],
});
setCategoryOptions(options);
} else {
console.error("❌ [SelectBasic] 카테고리 응답 실패:", response);
}
})
.catch((error) => {
console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error);
})
.finally(() => {
setIsLoadingCategories(false);
});
});
}
}, [webType, component.tableName, component.columnName]);
// 디버깅: menuObjid가 제대로 전달되는지 확인 // 디버깅: menuObjid가 제대로 전달되는지 확인
useEffect(() => { useEffect(() => {
if (codeCategory && codeCategory !== "none") { if (codeCategory && codeCategory !== "none") {
@ -207,42 +113,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 외부 value prop 변경 시 selectedValue 업데이트 // 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => { useEffect(() => {
const newValue = externalValue || config?.value || ""; const newValue = externalValue || config?.value || "";
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
console.log("🔍 [SelectBasic] 외부 값 변경 감지:", { if (newValue !== selectedValue) {
componentId: component.id, setSelectedValue(newValue);
columnName: (component as any).columnName,
isMultiple,
newValue,
selectedValue,
selectedValues,
externalValue,
"config.value": config?.value,
});
// 다중선택 모드인 경우
if (isMultiple) {
if (typeof newValue === "string" && newValue) {
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
const currentValuesStr = selectedValues.join(",");
if (newValue !== currentValuesStr) {
console.log("✅ [SelectBasic] 다중선택 값 업데이트:", {
from: selectedValues,
to: values,
});
setSelectedValues(values);
}
} else if (!newValue && selectedValues.length > 0) {
console.log("✅ [SelectBasic] 다중선택 값 초기화");
setSelectedValues([]);
}
} else {
// 단일선택 모드인 경우
if (newValue !== selectedValue) {
setSelectedValue(newValue);
}
} }
}, [externalValue, config?.value, isMultiple]); }, [externalValue, config?.value]);
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거 // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime) // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
@ -253,7 +128,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
useEffect(() => { useEffect(() => {
const getAllOptions = () => { const getAllOptions = () => {
const configOptions = config.options || []; const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions]; return [...codeOptions, ...configOptions];
}; };
const options = getAllOptions(); const options = getAllOptions();
@ -329,24 +204,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기 // 모든 옵션 가져오기
const getAllOptions = () => { const getAllOptions = () => {
const configOptions = config.options || []; const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions]; return [...codeOptions, ...configOptions];
}; };
const allOptions = getAllOptions(); const allOptions = getAllOptions();
const placeholder = componentConfig.placeholder || "선택하세요"; const placeholder = componentConfig.placeholder || "선택하세요";
// 🔍 디버깅: 최종 옵션 확인
useEffect(() => {
if (webType === "category" && allOptions.length > 0) {
console.log("🔍 [SelectBasic] 최종 allOptions:", {
count: allOptions.length,
categoryOptionsCount: categoryOptions.length,
codeOptionsCount: codeOptions.length,
sampleOptions: allOptions.slice(0, 3),
});
}
}, [webType, allOptions.length, categoryOptions.length, codeOptions.length]);
// DOM props에서 React 전용 props 필터링 // DOM props에서 React 전용 props 필터링
const { const {
component: _component, component: _component,
@ -637,96 +500,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
} }
// select (기본 선택박스) // select (기본 선택박스)
// 다중선택 모드인 경우
if (isMultiple) {
return (
<div className="w-full" style={{ height: "100%" }}>
<div
className={cn(
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
onClick={() => !isDesignMode && setIsOpen(true)}
style={{
pointerEvents: isDesignMode ? "none" : "auto",
height: "100%"
}}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);
return (
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
{opt?.label || val}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newVals = selectedValues.filter((v) => v !== val);
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
);
})}
{selectedValues.length === 0 && (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
const newVals = isSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="h-4 w-4"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
})
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
}
// 단일선택 모드
return ( return (
<div className="w-full"> <div className="w-full">
<div <div

View File

@ -21,9 +21,7 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onChange, onChange,
}) => { }) => {
const handleChange = (key: keyof SelectBasicConfig, value: any) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => {
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호) onChange({ [key]: value });
const newConfig = { ...config, [key]: value };
onChange(newConfig);
}; };
return ( return (
@ -69,15 +67,6 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("readonly", checked)} onCheckedChange={(checked) => handleChange("readonly", checked)}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="multiple"> </Label>
<Checkbox
id="multiple"
checked={config.multiple || false}
onCheckedChange={(checked) => handleChange("multiple", checked)}
/>
</div>
</div> </div>
); );
}; };

View File

@ -73,117 +73,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태 // 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({}); const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
// 🆕 추가 입력 필드별 자동 채우기 테이블 컬럼 상태
const [autoFillTableColumns, setAutoFillTableColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
// 🆕 원본 테이블 컬럼 로드
useEffect(() => {
if (!config.sourceTable) {
setLoadedSourceTableColumns([]);
return;
}
const loadColumns = async () => {
try {
console.log("🔍 원본 테이블 컬럼 로드:", config.sourceTable);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(config.sourceTable);
if (response.success && response.data) {
const columns = response.data.columns || [];
setLoadedSourceTableColumns(columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
})));
console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length);
}
} catch (error) {
console.error("❌ 원본 테이블 컬럼 로드 오류:", error);
}
};
loadColumns();
}, [config.sourceTable]);
// 🆕 대상 테이블 컬럼 로드
useEffect(() => {
if (!config.targetTable) {
setLoadedTargetTableColumns([]);
return;
}
const loadColumns = async () => {
try {
console.log("🔍 대상 테이블 컬럼 로드:", config.targetTable);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(config.targetTable);
if (response.success && response.data) {
const columns = response.data.columns || [];
setLoadedTargetTableColumns(columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
})));
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
}
} catch (error) {
console.error("❌ 대상 테이블 컬럼 로드 오류:", error);
}
};
loadColumns();
}, [config.targetTable]);
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
useEffect(() => {
if (!localFields || localFields.length === 0) return;
localFields.forEach((field, index) => {
if (field.autoFillFromTable && !autoFillTableColumns[index]) {
console.log(`🔍 [초기화] 필드 ${index}의 기존 테이블 컬럼 로드:`, field.autoFillFromTable);
loadAutoFillTableColumns(field.autoFillFromTable, index);
}
});
}, []); // 초기 한 번만 실행
// 🆕 자동 채우기 테이블 선택 시 컬럼 로드
const loadAutoFillTableColumns = async (tableName: string, fieldIndex: number) => {
if (!tableName) {
setAutoFillTableColumns(prev => ({ ...prev, [fieldIndex]: [] }));
return;
}
try {
console.log(`🔍 [필드 ${fieldIndex}] 자동 채우기 테이블 컬럼 로드:`, tableName);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data) {
const columns = response.data.columns || [];
setAutoFillTableColumns(prev => ({
...prev,
[fieldIndex]: columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
}))
}));
console.log(`✅ [필드 ${fieldIndex}] 컬럼 로드 성공:`, columns.length);
} else {
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 실패:`, response);
}
} catch (error) {
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 오류:`, error);
}
};
// 🆕 소스 테이블 선택 시 컬럼 로드 // 🆕 소스 테이블 선택 시 컬럼 로드
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => { const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
try { try {
@ -291,8 +180,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행) }, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => { const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
// 🔧 기존 config와 병합하여 다른 속성 유지 onChange({ [key]: value });
onChange({ ...config, [key]: value });
}; };
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => { const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
@ -373,19 +261,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록 // 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
const availableColumns = useMemo(() => { const availableColumns = useMemo(() => {
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
const columns = loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns;
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
return columns.filter((col) => !usedColumns.has(col.columnName)); return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName));
}, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]); }, [sourceTableColumns, displayColumns, localFields]);
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록 // 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
const availableTargetColumns = useMemo(() => { const availableTargetColumns = useMemo(() => {
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
const columns = loadedTargetTableColumns.length > 0 ? loadedTargetTableColumns : targetTableColumns;
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]); const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
return columns.filter((col) => !usedColumns.has(col.columnName)); return targetTableColumns.filter((col) => !usedColumns.has(col.columnName));
}, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]); }, [targetTableColumns, displayColumns, localFields]);
// 🆕 원본 테이블 필터링 // 🆕 원본 테이블 필터링
const filteredSourceTables = useMemo(() => { const filteredSourceTables = useMemo(() => {
@ -519,6 +403,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
role="combobox" role="combobox"
aria-expanded={sourceTableSelectOpen} aria-expanded={sourceTableSelectOpen}
className="h-8 w-full justify-between text-xs sm:text-sm" className="h-8 w-full justify-between text-xs sm:text-sm"
disabled={allTables.length === 0}
> >
{selectedSourceTableLabel} {selectedSourceTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
@ -792,66 +677,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[10px] sm:text-xs"> ()</Label> <Label className="text-[10px] sm:text-xs"> ()</Label>
{/* 테이블 선택 드롭다운 */} {/* 테이블명 입력 */}
<Popover> <Input
<PopoverTrigger asChild> value={field.autoFillFromTable || ""}
<Button onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })}
variant="outline" placeholder="비워두면 주 데이터 (예: item_price)"
role="combobox" className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs" />
>
{field.autoFillFromTable
? allTables.find(t => t.tableName === field.autoFillFromTable)?.displayName || field.autoFillFromTable
: "원본 테이블 (기본)"}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0 sm:w-[300px]">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
<CommandItem
value=""
onSelect={() => {
updateField(index, { autoFillFromTable: undefined, autoFillFrom: undefined });
setAutoFillTableColumns(prev => ({ ...prev, [index]: [] }));
}}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
!field.autoFillFromTable ? "opacity-100" : "opacity-0",
)}
/>
({config.sourceTable || "미설정"})
</CommandItem>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(value) => {
updateField(index, { autoFillFromTable: value, autoFillFrom: undefined });
loadAutoFillTableColumns(value, index);
}}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
field.autoFillFromTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[9px] text-gray-500 sm:text-[10px]"> <p className="text-[9px] text-gray-500 sm:text-[10px]">
</p> </p>
{/* 필드 선택 */} {/* 필드 선택 */}
@ -862,26 +696,16 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
role="combobox" role="combobox"
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs" className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
> >
{(() => { {field.autoFillFrom
if (!field.autoFillFrom) return "필드 선택 안 함"; ? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom
: "필드 선택 안 함"}
// 선택된 테이블의 컬럼에서 찾기
const columns = field.autoFillFromTable
? (autoFillTableColumns[index] || [])
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
const found = columns.find(c => c.columnName === field.autoFillFrom);
return found?.columnLabel || field.autoFillFrom;
})()}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" /> <ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[180px] p-0 sm:w-[200px]"> <PopoverContent className="w-[180px] p-0 sm:w-[200px]">
<Command> <Command>
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" /> <CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> <CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty>
{field.autoFillFromTable ? "컬럼을 찾을 수 없습니다" : "원본 테이블을 먼저 선택하세요"}
</CommandEmpty>
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]"> <CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
<CommandItem <CommandItem
value="" value=""
@ -896,32 +720,25 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
/> />
</CommandItem> </CommandItem>
{(() => { {sourceTableColumns.map((column) => (
// 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼 <CommandItem
const columns = field.autoFillFromTable key={column.columnName}
? (autoFillTableColumns[index] || []) value={column.columnName}
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns); onSelect={() => updateField(index, { autoFillFrom: column.columnName })}
className="text-[10px] sm:text-xs"
return columns.map((column) => ( >
<CommandItem <Check
key={column.columnName} className={cn(
value={column.columnName} "mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
onSelect={(value) => updateField(index, { autoFillFrom: value })} field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
className="text-[10px] sm:text-xs" )}
> />
<Check <div>
className={cn( <div className="font-medium">{column.columnLabel}</div>
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3", <div className="text-[9px] text-gray-500">{column.columnName}</div>
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0", </div>
)} </CommandItem>
/> ))}
<div>
<div className="font-medium">{column.columnLabel || column.columnName}</div>
{column.dataType && <div className="text-[8px] text-gray-500">{column.dataType}</div>}
</div>
</CommandItem>
));
})()}
</CommandGroup> </CommandGroup>
</Command> </Command>
</PopoverContent> </PopoverContent>

View File

@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
{/* 요약 표시 설정 (LIST 모드에서만) */} {/* 요약 표시 설정 (LIST 모드에서만) */}
{(config.rightPanel?.displayMode || "list") === "list" && ( {config.rightPanel?.displayMode === "list" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>

View File

@ -148,7 +148,7 @@ export interface TableListComponentProps {
tableName?: string; tableName?: string;
onRefresh?: () => void; onRefresh?: () => void;
onClose?: () => void; onClose?: () => void;
screenId?: number | string; // 화면 ID (필터 설정 저장용) screenId?: string;
userId?: string; // 사용자 ID (컬럼 순서 저장용) userId?: string; // 사용자 ID (컬럼 순서 저장용)
onSelectedRowsChange?: ( onSelectedRowsChange?: (
selectedRows: any[], selectedRows: any[],
@ -183,7 +183,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey, refreshKey,
tableName, tableName,
userId, userId,
screenId, // 화면 ID 추출
}) => { }) => {
// ======================================== // ========================================
// 설정 및 스타일 // 설정 및 스타일
@ -1228,9 +1227,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
// 체크박스 컬럼 (나중에 위치 결정) // 체크박스 컬럼 (나중에 위치 결정)
// 기본값: enabled가 undefined면 true로 처리
let checkboxCol: ColumnConfig | null = null; let checkboxCol: ColumnConfig | null = null;
if (tableConfig.checkbox?.enabled ?? true) { if (tableConfig.checkbox?.enabled) {
checkboxCol = { checkboxCol = {
columnName: "__checkbox__", columnName: "__checkbox__",
displayName: "", displayName: "",
@ -1259,7 +1257,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 체크박스를 맨 앞 또는 맨 뒤에 추가 // 체크박스를 맨 앞 또는 맨 뒤에 추가
if (checkboxCol) { if (checkboxCol) {
if (tableConfig.checkbox?.position === "right") { if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol]; cols = [...cols, checkboxCol];
} else { } else {
cols = [checkboxCol, ...cols]; cols = [checkboxCol, ...cols];
@ -1425,73 +1423,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
); );
} }
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (inputType === "category") { if (inputType === "category") {
if (!value) return ""; if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const { Badge } = require("@/components/ui/badge"); const categoryData = mapping?.[String(value)];
// 다중 값 처리: 콤마로 구분된 값들을 분리 // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
const valueStr = String(value); const displayLabel = categoryData?.label || String(value);
const values = valueStr.includes(",") const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
? valueStr.split(",").map(v => v.trim()).filter(v => v)
: [valueStr];
// 단일 값인 경우 (기존 로직) // 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (values.length === 1) { if (displayColor === "none") {
const categoryData = mapping?.[values[0]]; return <span className="text-sm">{displayLabel}</span>;
const displayLabel = categoryData?.label || values[0];
const displayColor = categoryData?.color || "#64748b";
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
} }
// 다중 값인 경우: 여러 배지 렌더링 const { Badge } = require("@/components/ui/badge");
return ( return (
<div className="flex flex-wrap gap-1"> <Badge
{values.map((val, idx) => { style={{
const categoryData = mapping?.[val]; backgroundColor: displayColor,
const displayLabel = categoryData?.label || val; borderColor: displayColor,
const displayColor = categoryData?.color || "#64748b"; }}
className="text-white"
if (displayColor === "none") { >
return ( {displayLabel}
<span key={idx} className="text-sm"> </Badge>
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
);
}
return (
<Badge
key={idx}
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
})}
</div>
); );
} }
@ -1577,21 +1535,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// useEffect 훅 // useEffect 훅
// ======================================== // ========================================
// 필터 설정 localStorage 키 생성 (화면별로 독립적) // 필터 설정 localStorage 키 생성
const filterSettingKey = useMemo(() => { const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null; if (!tableConfig.selectedTable) return null;
return screenId return `tableList_filterSettings_${tableConfig.selectedTable}`;
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` }, [tableConfig.selectedTable]);
: `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
// 그룹 설정 localStorage 키 생성 (화면별로 독립적) // 그룹 설정 localStorage 키 생성
const groupSettingKey = useMemo(() => { const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null; if (!tableConfig.selectedTable) return null;
return screenId return `tableList_groupSettings_${tableConfig.selectedTable}`;
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` }, [tableConfig.selectedTable]);
: `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
// 저장된 필터 설정 불러오기 // 저장된 필터 설정 불러오기
useEffect(() => { useEffect(() => {

View File

@ -269,9 +269,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// }); // });
const parentValue = config[parentKey] as any; const parentValue = config[parentKey] as any;
// 전체 config와 병합하여 다른 속성 유지
const newConfig = { const newConfig = {
...config,
[parentKey]: { [parentKey]: {
...parentValue, ...parentValue,
[childKey]: value, [childKey]: value,
@ -756,52 +754,6 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 체크박스 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
</div>
<hr className="border-border" />
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxEnabled"
checked={config.checkbox?.enabled ?? true}
onCheckedChange={(checked) => handleNestedChange("checkbox", "enabled", checked)}
/>
<Label htmlFor="checkboxEnabled"> </Label>
</div>
{config.checkbox?.enabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxSelectAll"
checked={config.checkbox?.selectAll ?? true}
onCheckedChange={(checked) => handleNestedChange("checkbox", "selectAll", checked)}
/>
<Label htmlFor="checkboxSelectAll"> </Label>
</div>
<div className="space-y-1">
<Label htmlFor="checkboxPosition" className="text-xs">
</Label>
<select
id="checkboxPosition"
value={config.checkbox?.position || "left"}
onChange={(e) => handleNestedChange("checkbox", "position", e.target.value)}
className="w-full h-8 text-xs border rounded-md px-2"
>
<option value="left"></option>
<option value="right"></option>
</select>
</div>
</>
)}
</div>
</div>
{/* 가로 스크롤 및 컬럼 고정 */} {/* 가로 스크롤 및 컬럼 고정 */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>

View File

@ -12,14 +12,6 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options"; import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface PresetFilter {
id: string;
columnName: string;
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
}
interface TableSearchWidgetProps { interface TableSearchWidgetProps {
component: { component: {
id: string; id: string;
@ -33,8 +25,6 @@ interface TableSearchWidgetProps {
componentConfig?: { componentConfig?: {
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부 autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부 showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
filterMode?: "dynamic" | "preset"; // 필터 모드
presetFilters?: PresetFilter[]; // 고정 필터 목록
}; };
}; };
screenId?: number; // 화면 ID screenId?: number; // 화면 ID
@ -73,8 +63,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true; const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
const showTableSelector = component.componentConfig?.showTableSelector ?? true; const showTableSelector = component.componentConfig?.showTableSelector ?? true;
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
const presetFilters = component.componentConfig?.presetFilters ?? [];
// Map을 배열로 변환 // Map을 배열로 변환
const tableList = Array.from(registeredTables.values()); const tableList = Array.from(registeredTables.values());
@ -89,58 +77,41 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) // 현재 테이블의 저장된 필터 불러오기
useEffect(() => { useEffect(() => {
if (!currentTable?.tableName) return; if (currentTable?.tableName) {
const storageKey = `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
// 고정 모드: presetFilters를 activeFilters로 설정 if (savedFilters) {
if (filterMode === "preset") { try {
const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({ const parsed = JSON.parse(savedFilters) as Array<{
columnName: f.columnName, columnName: string;
operator: "contains", columnLabel: string;
value: "", inputType: string;
filterType: f.filterType, enabled: boolean;
width: f.width || 200, filterType: "text" | "number" | "date" | "select";
})); width?: number;
setActiveFilters(activeFiltersList); }>;
return;
}
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기 // enabled된 필터들만 activeFilters로 설정
const storageKey = screenId const activeFiltersList: TableFilter[] = parsed
? `table_filters_${currentTable.tableName}_screen_${screenId}` .filter((f) => f.enabled)
: `table_filters_${currentTable.tableName}`; .map((f) => ({
const savedFilters = localStorage.getItem(storageKey); columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
}));
if (savedFilters) { setActiveFilters(activeFiltersList);
try { } catch (error) {
const parsed = JSON.parse(savedFilters) as Array<{ console.error("저장된 필터 불러오기 실패:", error);
columnName: string; }
columnLabel: string;
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number;
}>;
// enabled된 필터들만 activeFilters로 설정
const activeFiltersList: TableFilter[] = parsed
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
}));
setActiveFilters(activeFiltersList);
} catch (error) {
console.error("저장된 필터 불러오기 실패:", error);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTable?.tableName]);
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지) // select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => { useEffect(() => {
@ -391,7 +362,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
{/* 필터가 없을 때는 빈 공간 */} {/* 필터가 없을 때는 빈 공간 */}
{activeFilters.length === 0 && <div className="flex-1" />} {activeFilters.length === 0 && <div className="flex-1" />}
{/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */} {/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
{/* 데이터 건수 표시 */} {/* 데이터 건수 표시 */}
{currentTable?.dataCount !== undefined && ( {currentTable?.dataCount !== undefined && (
@ -400,43 +371,38 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div> </div>
)} )}
{/* 동적 모드일 때만 설정 버튼들 표시 */} <Button
{filterMode === "dynamic" && ( variant="outline"
<> size="sm"
<Button onClick={() => setColumnVisibilityOpen(true)}
variant="outline" disabled={!selectedTableId}
size="sm" className="h-8 text-xs sm:h-9 sm:text-sm"
onClick={() => setColumnVisibilityOpen(true)} >
disabled={!selectedTableId} <Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
className="h-8 text-xs sm:h-9 sm:text-sm"
> </Button>
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setFilterOpen(true)} onClick={() => setFilterOpen(true)}
disabled={!selectedTableId} disabled={!selectedTableId}
className="h-8 text-xs sm:h-9 sm:text-sm" className="h-8 text-xs sm:h-9 sm:text-sm"
> >
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> <Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setGroupingOpen(true)} onClick={() => setGroupingOpen(true)}
disabled={!selectedTableId} disabled={!selectedTableId}
className="h-8 text-xs sm:h-9 sm:text-sm" className="h-8 text-xs sm:h-9 sm:text-sm"
> >
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> <Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
</>
)}
</div> </div>
{/* 패널들 */} {/* 패널들 */}
@ -445,7 +411,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
isOpen={filterOpen} isOpen={filterOpen}
onClose={() => setFilterOpen(false)} onClose={() => setFilterOpen(false)}
onFiltersApplied={(filters) => setActiveFilters(filters)} onFiltersApplied={(filters) => setActiveFilters(filters)}
screenId={screenId}
/> />
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} /> <GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
</div> </div>

View File

@ -3,126 +3,27 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, X } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TableSearchWidgetConfigPanelProps { interface TableSearchWidgetConfigPanelProps {
component?: any; // 레거시 지원 component: any;
config?: any; // 새 인터페이스 onUpdateProperty: (property: string, value: any) => void;
onUpdateProperty?: (property: string, value: any) => void; // 레거시 지원
onChange?: (newConfig: any) => void; // 새 인터페이스
tables?: any[]; // 화면의 테이블 정보
}
interface PresetFilter {
id: string;
columnName: string;
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
} }
export function TableSearchWidgetConfigPanel({ export function TableSearchWidgetConfigPanel({
component, component,
config,
onUpdateProperty, onUpdateProperty,
onChange,
tables = [],
}: TableSearchWidgetConfigPanelProps) { }: TableSearchWidgetConfigPanelProps) {
// 레거시와 새 인터페이스 모두 지원
const currentConfig = config || component?.componentConfig || {};
const updateConfig = onChange || ((key: string, value: any) => {
if (onUpdateProperty) {
onUpdateProperty(`componentConfig.${key}`, value);
}
});
// 첫 번째 테이블의 컬럼 목록 가져오기
const availableColumns = tables.length > 0 && tables[0].columns ? tables[0].columns : [];
// inputType에서 filterType 추출 헬퍼 함수
const getFilterTypeFromInputType = (inputType: string): "text" | "number" | "date" | "select" => {
if (inputType.includes("number") || inputType.includes("decimal") || inputType.includes("integer")) {
return "number";
}
if (inputType.includes("date") || inputType.includes("time")) {
return "date";
}
if (inputType.includes("select") || inputType.includes("dropdown") || inputType.includes("code") || inputType.includes("category")) {
return "select";
}
return "text";
};
const [localAutoSelect, setLocalAutoSelect] = useState( const [localAutoSelect, setLocalAutoSelect] = useState(
currentConfig.autoSelectFirstTable ?? true component.componentConfig?.autoSelectFirstTable ?? true
); );
const [localShowSelector, setLocalShowSelector] = useState( const [localShowSelector, setLocalShowSelector] = useState(
currentConfig.showTableSelector ?? true component.componentConfig?.showTableSelector ?? true
);
const [localFilterMode, setLocalFilterMode] = useState<"dynamic" | "preset">(
currentConfig.filterMode ?? "dynamic"
);
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
currentConfig.presetFilters ?? []
); );
useEffect(() => { useEffect(() => {
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true); setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
setLocalShowSelector(currentConfig.showTableSelector ?? true); setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
setLocalFilterMode(currentConfig.filterMode ?? "dynamic"); }, [component.componentConfig]);
setLocalPresetFilters(currentConfig.presetFilters ?? []);
}, [currentConfig]);
// 설정 업데이트 헬퍼
const handleUpdate = (key: string, value: any) => {
if (onChange) {
// 새 인터페이스: 전체 config 업데이트
onChange({ ...currentConfig, [key]: value });
} else if (onUpdateProperty) {
// 레거시: 개별 속성 업데이트
onUpdateProperty(`componentConfig.${key}`, value);
}
};
// 필터 추가
const addFilter = () => {
const newFilter: PresetFilter = {
id: `filter_${Date.now()}`,
columnName: "",
columnLabel: "",
filterType: "text",
width: 200,
};
const updatedFilters = [...localPresetFilters, newFilter];
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
};
// 필터 삭제
const removeFilter = (id: string) => {
const updatedFilters = localPresetFilters.filter((f) => f.id !== id);
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
};
// 필터 업데이트
const updateFilter = (id: string, field: keyof PresetFilter, value: any) => {
const updatedFilters = localPresetFilters.map((f) =>
f.id === id ? { ...f, [field]: value } : f
);
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -140,7 +41,7 @@ export function TableSearchWidgetConfigPanel({
checked={localAutoSelect} checked={localAutoSelect}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setLocalAutoSelect(checked as boolean); setLocalAutoSelect(checked as boolean);
handleUpdate("autoSelectFirstTable", checked); onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
}} }}
/> />
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer"> <Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
@ -155,7 +56,7 @@ export function TableSearchWidgetConfigPanel({
checked={localShowSelector} checked={localShowSelector}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setLocalShowSelector(checked as boolean); setLocalShowSelector(checked as boolean);
handleUpdate("showTableSelector", checked); onUpdateProperty("componentConfig.showTableSelector", checked);
}} }}
/> />
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer"> <Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
@ -163,178 +64,12 @@ export function TableSearchWidgetConfigPanel({
</Label> </Label>
</div> </div>
{/* 필터 모드 선택 */}
<div className="space-y-2 border-t pt-4">
<Label className="text-xs sm:text-sm font-medium"> </Label>
<RadioGroup
value={localFilterMode}
onValueChange={(value: "dynamic" | "preset") => {
setLocalFilterMode(value);
handleUpdate("filterMode", value);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dynamic" id="mode-dynamic" />
<Label htmlFor="mode-dynamic" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="preset" id="mode-preset" />
<Label htmlFor="mode-preset" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
</RadioGroup>
</div>
{/* 고정 모드일 때만 필터 설정 UI 표시 */}
{localFilterMode === "preset" && (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm font-medium"> </Label>
<Button
variant="outline"
size="sm"
onClick={addFilter}
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{localPresetFilters.length === 0 ? (
<div className="rounded-md bg-muted p-3 text-center text-xs text-muted-foreground">
. .
</div>
) : (
<div className="space-y-2">
{localPresetFilters.map((filter) => (
<div
key={filter.id}
className="rounded-md border bg-card p-3 space-y-2"
>
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
variant="ghost"
size="sm"
onClick={() => removeFilter(filter.id)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-[10px] sm:text-xs mb-1"> </Label>
{availableColumns.length > 0 ? (
<Select
value={filter.columnName}
onValueChange={(value) => {
// 선택된 컬럼 정보 가져오기
const selectedColumn = availableColumns.find(
(col: any) => col.columnName === value
);
// 컬럼명과 라벨 동시 업데이트
const updatedFilters = localPresetFilters.map((f) =>
f.id === filter.id
? {
...f,
columnName: value,
columnLabel: selectedColumn?.columnLabel || value,
filterType: getFilterTypeFromInputType(selectedColumn?.inputType || "text"),
}
: f
);
setLocalPresetFilters(updatedFilters);
handleUpdate("presetFilters", updatedFilters);
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span className="font-medium">{col.columnLabel}</span>
<span className="text-muted-foreground text-[10px]">
({col.columnName})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={filter.columnName}
onChange={(e) => updateFilter(filter.id, "columnName", e.target.value)}
placeholder="예: customer_name"
className="h-7 text-xs"
/>
)}
{filter.columnLabel && (
<p className="text-muted-foreground mt-1 text-[10px]">
: {filter.columnLabel}
</p>
)}
</div>
{/* 필터 타입 */}
<div>
<Label className="text-[10px] sm:text-xs mb-1"> </Label>
<Select
value={filter.filterType}
onValueChange={(value: "text" | "number" | "date" | "select") =>
updateFilter(filter.id, "filterType", value)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 너비 */}
<div>
<Label className="text-[10px] sm:text-xs mb-1"> (px)</Label>
<Input
type="number"
value={filter.width || 200}
onChange={(e) => updateFilter(filter.id, "width", parseInt(e.target.value))}
placeholder="200"
className="h-7 text-xs"
min={100}
max={500}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="rounded-md bg-muted p-3 text-xs"> <div className="rounded-md bg-muted p-3 text-xs">
<p className="font-medium mb-1">:</p> <p className="font-medium mb-1">:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> <ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li> , , </li> <li> , , </li>
<li> </li> <li> </li>
{localFilterMode === "dynamic" ? ( <li> </li>
<li> </li>
) : (
<li> </li>
)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -2,8 +2,8 @@ import React from "react";
import { TableSearchWidget } from "./TableSearchWidget"; import { TableSearchWidget } from "./TableSearchWidget";
export class TableSearchWidgetRenderer { export class TableSearchWidgetRenderer {
static render(component: any, props?: any) { static render(component: any) {
return <TableSearchWidget component={component} screenId={props?.screenId} />; return <TableSearchWidget component={component} />;
} }
} }