Compare commits
19 Commits
e2f4b47588
...
7ab3781372
| Author | SHA1 | Date |
|---|---|---|
|
|
7ab3781372 | |
|
|
3f32996014 | |
|
|
a868c5c413 | |
|
|
15f21a1142 | |
|
|
02644f38ee | |
|
|
ce3ba22c54 | |
|
|
61dc48e638 | |
|
|
4b540dc587 | |
|
|
e9f0244210 | |
|
|
68c3db5213 | |
|
|
94846e92ef | |
|
|
e10d6a3b94 | |
|
|
68577a09f9 | |
|
|
3009d1eecc | |
|
|
afea879920 | |
|
|
672aba8404 | |
|
|
efaa267d78 | |
|
|
7835898a09 | |
|
|
25740c499d |
|
|
@ -0,0 +1,281 @@
|
|||
# AI-개발자 협업 작업 수칙
|
||||
|
||||
## 핵심 원칙: "추측 금지, 확인 필수"
|
||||
|
||||
AI는 코드 작성 전에 반드시 실제 상황을 확인해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 관련 작업
|
||||
|
||||
### 필수 확인 사항
|
||||
|
||||
- ✅ **항상 MCP Postgres로 실제 테이블 구조를 먼저 확인**
|
||||
- ✅ 컬럼명, 데이터 타입, 제약조건을 추측하지 말고 쿼리로 확인
|
||||
- ✅ 변경 후 실제로 데이터가 어떻게 보이는지 SELECT로 검증
|
||||
|
||||
### 확인 방법
|
||||
|
||||
```sql
|
||||
-- 테이블 구조 확인
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '테이블명'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- 실제 데이터 확인
|
||||
SELECT * FROM 테이블명 LIMIT 5;
|
||||
```
|
||||
|
||||
### 금지 사항
|
||||
|
||||
- ❌ "아마도 `created_at` 컬럼일 것입니다" → 확인 필수!
|
||||
- ❌ "보통 이렇게 되어있습니다" → 이 프로젝트에서 확인!
|
||||
- ❌ 다른 테이블 구조를 보고 추측 → 각 테이블마다 확인!
|
||||
|
||||
---
|
||||
|
||||
## 2. 코드 수정 작업
|
||||
|
||||
### 작업 전
|
||||
|
||||
1. **관련 파일 읽기**: 수정할 파일의 현재 상태 확인
|
||||
2. **의존성 파악**: 다른 파일에 영향이 있는지 검색
|
||||
3. **기존 패턴 확인**: 프로젝트의 코딩 스타일 준수
|
||||
|
||||
### 작업 중
|
||||
|
||||
1. **한 번에 하나씩**: 하나의 명확한 작업만 수행
|
||||
2. **로그 추가**: 디버깅이 필요하면 명확한 로그 추가
|
||||
3. **점진적 수정**: 큰 변경은 여러 단계로 나눔
|
||||
|
||||
### 작업 후
|
||||
|
||||
1. **로그 제거**: 디버깅 로그는 반드시 제거
|
||||
2. **테스트 제안**: 브라우저로 테스트할 것을 제안
|
||||
3. **변경사항 요약**: 무엇을 어떻게 바꿨는지 명확히 설명
|
||||
|
||||
---
|
||||
|
||||
## 3. 확인 및 검증
|
||||
|
||||
### 확인 도구 사용
|
||||
|
||||
- **MCP Postgres**: 데이터베이스 구조 및 데이터 확인
|
||||
- **MCP Browser**: 실제 화면에서 동작 확인
|
||||
- **codebase_search**: 관련 코드 패턴 검색
|
||||
- **grep**: 특정 문자열 사용처 찾기
|
||||
|
||||
### 검증 프로세스
|
||||
|
||||
1. **변경 전 상태 확인** → 문제 파악
|
||||
2. **변경 적용**
|
||||
3. **변경 후 상태 확인** → 해결 검증
|
||||
4. **부작용 확인** → 다른 기능에 영향 없는지
|
||||
|
||||
### 사용자 피드백 대응
|
||||
|
||||
- 사용자가 "확인 안하지?"라고 하면:
|
||||
1. 즉시 사과
|
||||
2. MCP/브라우저로 실제 확인
|
||||
3. 정확한 정보를 바탕으로 재작업
|
||||
|
||||
---
|
||||
|
||||
## 4. 커뮤니케이션
|
||||
|
||||
### 작업 시작 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"MCP로 item_info 테이블 구조를 먼저 확인하겠습니다."
|
||||
|
||||
❌ 나쁜 예:
|
||||
"보통 created_at 컬럼이 있을 것이므로 수정하겠습니다."
|
||||
```
|
||||
|
||||
### 작업 완료 시
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"완료! 두 가지를 수정했습니다:
|
||||
1. 기본 높이를 40px → 30px로 변경 (ScreenDesigner.tsx:2174)
|
||||
2. 숨김 컬럼을 created_date, updated_date, writer, company_code로 수정 (TablesPanel.tsx:57)
|
||||
|
||||
테스트해보세요!"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"수정했습니다!"
|
||||
```
|
||||
|
||||
### 불확실할 때
|
||||
|
||||
```
|
||||
✅ 좋은 예:
|
||||
"컬럼명이 created_at인지 created_date인지 확실하지 않습니다.
|
||||
MCP로 확인해도 될까요?"
|
||||
|
||||
❌ 나쁜 예:
|
||||
"created_at일 것 같으니 일단 이렇게 하겠습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 금지 사항
|
||||
|
||||
### 절대 금지
|
||||
|
||||
1. ❌ **확인 없이 "완료했습니다" 말하기**
|
||||
- 반드시 실제로 확인하고 보고
|
||||
2. ❌ **이전에 실패한 방법 반복하기**
|
||||
- 같은 실수를 두 번 하지 않기
|
||||
3. ❌ **디버깅 로그를 남겨둔 채 작업 종료**
|
||||
- 모든 console.log 제거 확인
|
||||
4. ❌ **추측으로 답변하기**
|
||||
|
||||
- "아마도", "보통", "일반적으로" 금지
|
||||
- 확실하지 않으면 먼저 확인
|
||||
|
||||
5. ❌ **여러 문제를 한 번에 수정하려고 시도**
|
||||
- 한 번에 하나씩 해결
|
||||
|
||||
---
|
||||
|
||||
## 6. 프로젝트 특별 규칙
|
||||
|
||||
### 백엔드 관련
|
||||
|
||||
- 🔥 **백엔드 재시작 절대 금지** (사용자 명시 규칙)
|
||||
- 🔥 Node.js 프로세스를 건드리지 않음
|
||||
|
||||
### 데이터베이스 관련
|
||||
|
||||
- 🔥 **멀티테넌시 규칙 준수**
|
||||
- 모든 쿼리에 `company_code` 필터링 필수
|
||||
- `company_code = "*"`는 최고 관리자 전용
|
||||
- 자세한 내용: `.cursor/rules/multi-tenancy-guide.mdc`
|
||||
|
||||
### API 관련
|
||||
|
||||
- 🔥 **API 클라이언트 사용 필수**
|
||||
- `fetch()` 직접 사용 금지
|
||||
- `lib/api/` 의 클라이언트 함수 사용
|
||||
- 환경별 URL 자동 처리
|
||||
|
||||
### UI 관련
|
||||
|
||||
- 🔥 **shadcn/ui 스타일 가이드 준수**
|
||||
- CSS 변수 사용 (하드코딩 금지)
|
||||
- 중첩 박스 금지 (명시 요청 전까지)
|
||||
- 이모지 사용 금지 (명시 요청 전까지)
|
||||
|
||||
---
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
### 에러 발생 시 프로세스
|
||||
|
||||
1. **에러 로그 전체 읽기**
|
||||
|
||||
- 스택 트레이스 확인
|
||||
- 에러 메시지 정확히 파악
|
||||
|
||||
2. **근본 원인 파악**
|
||||
|
||||
- 증상이 아닌 원인 찾기
|
||||
- 왜 이 에러가 발생했는지 이해
|
||||
|
||||
3. **해결책 적용**
|
||||
|
||||
- 임시방편이 아닌 근본적 해결
|
||||
- 같은 에러가 재발하지 않도록
|
||||
|
||||
4. **검증**
|
||||
- 실제로 에러가 해결되었는지 확인
|
||||
- 다른 부작용은 없는지 확인
|
||||
|
||||
### 에러 로깅
|
||||
|
||||
```typescript
|
||||
// ✅ 좋은 로그 (디버깅 시)
|
||||
console.log("🔍 [컴포넌트명] 작업명:", {
|
||||
관련변수1,
|
||||
관련변수2,
|
||||
예상결과,
|
||||
});
|
||||
|
||||
// ❌ 나쁜 로그
|
||||
console.log("here");
|
||||
console.log(data); // 무슨 데이터인지 알 수 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 완료 체크리스트
|
||||
|
||||
모든 작업 완료 전에 다음을 확인:
|
||||
|
||||
- [ ] 실제 데이터베이스/파일을 확인했는가?
|
||||
- [ ] 변경사항이 의도대로 작동하는가?
|
||||
- [ ] 디버깅 로그를 모두 제거했는가?
|
||||
- [ ] 다른 기능에 부작용이 없는가?
|
||||
- [ ] 멀티테넌시 규칙을 준수했는가?
|
||||
- [ ] 사용자에게 명확히 설명했는가?
|
||||
|
||||
---
|
||||
|
||||
## 9. 모범 사례
|
||||
|
||||
### 데이터베이스 확인 예시
|
||||
|
||||
```typescript
|
||||
// 1. MCP로 테이블 구조 확인
|
||||
mcp_postgres_query: SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'item_info';
|
||||
|
||||
// 2. 실제 컬럼명 확인 후 코드 작성
|
||||
const hiddenColumns = new Set([
|
||||
'id',
|
||||
'created_date', // ✅ 실제 확인한 컬럼명
|
||||
'updated_date', // ✅ 실제 확인한 컬럼명
|
||||
'writer', // ✅ 실제 확인한 컬럼명
|
||||
'company_code'
|
||||
]);
|
||||
```
|
||||
|
||||
### 브라우저 테스트 제안 예시
|
||||
|
||||
```
|
||||
"수정이 완료되었습니다!
|
||||
|
||||
다음을 테스트해주세요:
|
||||
1. 화면관리 > 테이블 탭 열기
|
||||
2. item_info 테이블 확인
|
||||
3. 기본 5개 컬럼(id, created_date 등)이 안 보이는지 확인
|
||||
4. 새 컬럼 드래그앤드롭 시 높이가 30px인지 확인
|
||||
|
||||
브라우저 테스트를 원하시면 말씀해주세요!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 요약: 핵심 3원칙
|
||||
|
||||
1. **확인 우선** 🔍
|
||||
|
||||
- 추측하지 말고, 항상 확인하고 작업
|
||||
|
||||
2. **한 번에 하나** 🎯
|
||||
|
||||
- 여러 문제를 동시에 해결하려 하지 말기
|
||||
|
||||
3. **철저한 마무리** ✨
|
||||
- 로그 제거, 테스트, 명확한 설명
|
||||
|
||||
---
|
||||
|
||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||
17
.cursorrules
17
.cursorrules
|
|
@ -1,22 +1,5 @@
|
|||
# Cursor Rules for ERP-node Project
|
||||
|
||||
## 🔥 필수 확인 규칙 (작업 시작 전 & 완료 후)
|
||||
|
||||
**AI 에이전트는 모든 작업을 시작하기 전과 완료한 후에 반드시 다음 파일을 확인해야 합니다:**
|
||||
- [AI-개발자 협업 작업 수칙](.cursor/rules/ai-developer-collaboration-rules.mdc)
|
||||
|
||||
**핵심 3원칙:**
|
||||
1. **확인 우선** 🔍 - 추측하지 말고, 항상 확인하고 작업
|
||||
2. **한 번에 하나** 🎯 - 여러 문제를 동시에 해결하려 하지 말기
|
||||
3. **철저한 마무리** ✨ - 로그 제거, 테스트, 명확한 설명
|
||||
|
||||
**절대 금지:**
|
||||
- ❌ 확인 없이 "완료했습니다" 말하기
|
||||
- ❌ 데이터베이스 컬럼명 추측하기 (반드시 MCP로 확인)
|
||||
- ❌ 디버깅 로그를 남겨둔 채 작업 종료
|
||||
|
||||
---
|
||||
|
||||
## 🚨 최우선 보안 규칙: 멀티테넌시
|
||||
|
||||
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**
|
||||
|
|
|
|||
|
|
@ -286,5 +286,4 @@ uploads/
|
|||
*.hwp
|
||||
*.hwpx
|
||||
|
||||
claude.md
|
||||
.cursor/rules/ai-developer-collaboration-rules.mdc
|
||||
claude.md
|
||||
|
|
@ -51,21 +51,26 @@ router.get(
|
|||
}
|
||||
}
|
||||
|
||||
// 회사 코드 추출 (멀티테넌시 필터링)
|
||||
const userCompany = req.user?.companyCode;
|
||||
|
||||
console.log(`🔗 조인 데이터 조회:`, {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
userCompany,
|
||||
});
|
||||
|
||||
// 조인 데이터 조회
|
||||
// 조인 데이터 조회 (회사 코드 전달)
|
||||
const result = await dataService.getJoinedData(
|
||||
leftTable as string,
|
||||
rightTable as string,
|
||||
leftColumn as string,
|
||||
rightColumn as string,
|
||||
leftValue as string
|
||||
leftValue as string,
|
||||
userCompany
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -352,8 +357,25 @@ router.post(
|
|||
|
||||
console.log(`➕ 레코드 생성: ${tableName}`, data);
|
||||
|
||||
// company_code와 company_name 자동 추가 (멀티테넌시)
|
||||
const enrichedData = { ...data };
|
||||
|
||||
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
|
||||
const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code");
|
||||
if (hasCompanyCode && req.user?.companyCode) {
|
||||
enrichedData.company_code = req.user.companyCode;
|
||||
console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`);
|
||||
}
|
||||
|
||||
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
|
||||
const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name");
|
||||
if (hasCompanyName && req.user?.companyName) {
|
||||
enrichedData.company_name = req.user.companyName;
|
||||
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
|
||||
}
|
||||
|
||||
// 레코드 생성
|
||||
const result = await dataService.createRecord(tableName, data);
|
||||
const result = await dataService.createRecord(tableName, enrichedData);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
|
|
@ -437,6 +459,58 @@ router.put(
|
|||
* 레코드 삭제 API
|
||||
* DELETE /api/data/{tableName}/{id}
|
||||
*/
|
||||
/**
|
||||
* 복합키 레코드 삭제 API (POST)
|
||||
* POST /api/data/:tableName/delete
|
||||
* Body: { user_id: 'xxx', dept_code: 'yyy' }
|
||||
*/
|
||||
router.post(
|
||||
"/:tableName/delete",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const compositeKey = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 복합키 레코드 삭제: ${tableName}`, compositeKey);
|
||||
|
||||
// 레코드 삭제 (복합키 객체 전달)
|
||||
const result = await dataService.deleteRecord(tableName, compositeKey);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 삭제 성공: ${tableName}`);
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
|
|
|
|||
|
|
@ -165,12 +165,13 @@ export class AuthService {
|
|||
const authNames = authResult.map((row) => row.auth_name).join(",");
|
||||
|
||||
// 3. 회사 정보 조회 (Raw Query 전환)
|
||||
// Note: 현재 회사 정보는 PersonBean에 직접 사용되지 않지만 향후 확장을 위해 유지
|
||||
const companyResult = await query<{ company_name: string }>(
|
||||
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||
[userInfo.company_code || "ILSHIN"]
|
||||
);
|
||||
|
||||
const companyName = companyResult.length > 0 ? companyResult[0].company_name : undefined;
|
||||
|
||||
// DB에서 조회한 원본 사용자 정보 상세 로그
|
||||
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||
// userId: userInfo.user_id,
|
||||
|
|
@ -205,6 +206,7 @@ export class AuthService {
|
|||
partnerObjid: userInfo.partner_objid || undefined,
|
||||
authName: authNames || undefined,
|
||||
companyCode: companyCode,
|
||||
companyName: companyName, // 회사명 추가
|
||||
photo: userInfo.photo
|
||||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -231,6 +231,9 @@ class DataService {
|
|||
|
||||
const columns = await this.getTableColumnsSimple(tableName);
|
||||
|
||||
// PK 컬럼 정보 조회
|
||||
const pkColumns = await this.getPrimaryKeyColumns(tableName);
|
||||
|
||||
// 컬럼 라벨 정보 추가
|
||||
const columnsWithLabels = await Promise.all(
|
||||
columns.map(async (column) => {
|
||||
|
|
@ -244,6 +247,7 @@ class DataService {
|
|||
dataType: column.data_type,
|
||||
isNullable: column.is_nullable === "YES",
|
||||
defaultValue: column.column_default,
|
||||
isPrimaryKey: pkColumns.includes(column.column_name), // PK 여부 추가
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -262,6 +266,26 @@ class DataService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 Primary Key 컬럼 목록 조회
|
||||
*/
|
||||
private async getPrimaryKeyColumns(tableName: string): Promise<string[]> {
|
||||
try {
|
||||
const result = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return result.map((row) => row.attname);
|
||||
} catch (error) {
|
||||
console.error(`PK 컬럼 조회 오류 (${tableName}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
*/
|
||||
|
|
@ -286,7 +310,7 @@ class DataService {
|
|||
/**
|
||||
* 특정 컬럼 존재 여부 확인
|
||||
*/
|
||||
private async checkColumnExists(
|
||||
async checkColumnExists(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<boolean> {
|
||||
|
|
@ -409,7 +433,8 @@ class DataService {
|
|||
rightTable: string,
|
||||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftValue?: string | number
|
||||
leftValue?: string | number,
|
||||
userCompany?: string
|
||||
): Promise<ServiceResponse<any[]>> {
|
||||
try {
|
||||
// 왼쪽 테이블 접근 검증
|
||||
|
|
@ -425,18 +450,42 @@ class DataService {
|
|||
}
|
||||
|
||||
let queryText = `
|
||||
SELECT r.*
|
||||
SELECT DISTINCT r.*
|
||||
FROM "${rightTable}" r
|
||||
INNER JOIN "${leftTable}" l
|
||||
ON l."${leftColumn}" = r."${rightColumn}"
|
||||
`;
|
||||
|
||||
const values: any[] = [];
|
||||
const whereConditions: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 좌측 값 필터링
|
||||
if (leftValue !== undefined && leftValue !== null) {
|
||||
queryText += ` WHERE l."${leftColumn}" = $1`;
|
||||
whereConditions.push(`l."${leftColumn}" = $${paramIndex}`);
|
||||
values.push(leftValue);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
||||
if (userCompany && userCompany !== "*") {
|
||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||
if (hasCompanyCode) {
|
||||
whereConditions.push(`r.company_code = $${paramIndex}`);
|
||||
values.push(userCompany);
|
||||
paramIndex++;
|
||||
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
|
||||
}
|
||||
}
|
||||
|
||||
// WHERE 절 추가
|
||||
if (whereConditions.length > 0) {
|
||||
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
console.log("🔍 조인 쿼리 실행:", queryText);
|
||||
console.log("📊 조인 쿼리 파라미터:", values);
|
||||
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
return {
|
||||
|
|
@ -512,6 +561,11 @@ class DataService {
|
|||
return validation.error!;
|
||||
}
|
||||
|
||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||
const relationInfo = data._relationInfo;
|
||||
const cleanData = { ...data };
|
||||
delete cleanData._relationInfo;
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
|
|
@ -526,8 +580,8 @@ class DataService {
|
|||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const columns = Object.keys(cleanData);
|
||||
const values = Object.values(cleanData);
|
||||
const setClause = columns
|
||||
.map((col, index) => `"${col}" = $${index + 1}`)
|
||||
.join(", ");
|
||||
|
|
@ -550,6 +604,35 @@ class DataService {
|
|||
};
|
||||
}
|
||||
|
||||
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
||||
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
|
||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
|
||||
const newLeftValue = cleanData[leftColumn];
|
||||
|
||||
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
||||
if (newLeftValue !== undefined && newLeftValue !== oldLeftValue) {
|
||||
console.log("🔗 조인 관계 FK 업데이트:", {
|
||||
rightTable,
|
||||
rightColumn,
|
||||
oldValue: oldLeftValue,
|
||||
newValue: newLeftValue,
|
||||
});
|
||||
|
||||
try {
|
||||
const updateRelatedQuery = `
|
||||
UPDATE "${rightTable}"
|
||||
SET "${rightColumn}" = $1
|
||||
WHERE "${rightColumn}" = $2
|
||||
`;
|
||||
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
|
||||
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
|
||||
} catch (relError) {
|
||||
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
||||
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
|
|
@ -569,7 +652,7 @@ class DataService {
|
|||
*/
|
||||
async deleteRecord(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
id: string | number | Record<string, any>
|
||||
): Promise<ServiceResponse<void>> {
|
||||
try {
|
||||
// 테이블 접근 검증
|
||||
|
|
@ -578,28 +661,53 @@ class DataService {
|
|||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
// Primary Key 컬럼 찾기 (복합키 지원)
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
||||
ORDER BY a.attnum`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id";
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
let whereClauses: string[] = [];
|
||||
let params: any[] = [];
|
||||
|
||||
if (pkResult.length > 1) {
|
||||
// 복합키인 경우: id가 객체여야 함
|
||||
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
|
||||
|
||||
if (typeof id === 'object' && !Array.isArray(id)) {
|
||||
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
||||
pkResult.forEach((pk, index) => {
|
||||
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
||||
params.push(id[pk.attname]);
|
||||
});
|
||||
} else {
|
||||
// id가 문자열/숫자인 경우: 첫 번째 PK만 사용 (하위 호환성)
|
||||
whereClauses.push(`"${pkResult[0].attname}" = $1`);
|
||||
params.push(id);
|
||||
}
|
||||
} else {
|
||||
// 단일키인 경우
|
||||
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
||||
whereClauses.push(`"${pkColumn}" = $1`);
|
||||
params.push(typeof id === 'object' ? id[pkColumn] : id);
|
||||
}
|
||||
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await query<any>(queryText, [id]);
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
|
||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||
|
||||
const result = await query<any>(queryText, params);
|
||||
|
||||
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error);
|
||||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export interface PersonBean {
|
|||
partnerObjid?: string;
|
||||
authName?: string;
|
||||
companyCode?: string;
|
||||
companyName?: string; // 회사명 추가
|
||||
photo?: string;
|
||||
locale?: string;
|
||||
// 권한 레벨 정보 (3단계 체계)
|
||||
|
|
@ -94,6 +95,7 @@ export interface JwtPayload {
|
|||
userName: string;
|
||||
deptName?: string;
|
||||
companyCode?: string;
|
||||
companyName?: string; // 회사명 추가
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
iat?: number;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export class JwtUtils {
|
|||
userName: userInfo.userName,
|
||||
deptName: userInfo.deptName,
|
||||
companyCode: userInfo.companyCode,
|
||||
companyName: userInfo.companyName, // 회사명 추가
|
||||
userType: userInfo.userType,
|
||||
userTypeName: userInfo.userTypeName,
|
||||
};
|
||||
|
|
@ -45,6 +46,7 @@ export class JwtUtils {
|
|||
userName: decoded.userName,
|
||||
deptName: decoded.deptName,
|
||||
companyCode: decoded.companyCode,
|
||||
companyName: decoded.companyName, // 회사명 추가
|
||||
userType: decoded.userType,
|
||||
userTypeName: decoded.userTypeName,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -183,6 +183,15 @@ body {
|
|||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
/* Button 기본 커서 스타일 */
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== Dialog/Modal Overlay ===== */
|
||||
/* Radix UI Dialog Overlay - 60% 불투명도 배경 */
|
||||
[data-radix-dialog-overlay],
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ interface RealtimePreviewProps {
|
|||
isSelected?: boolean;
|
||||
isDesignMode?: boolean;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.MouseEvent | React.DragEvent) => void; // MouseEvent도 허용
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
||||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||
|
|
@ -247,13 +247,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
|
||||
// 🔍 [디버깅] 렌더링 시 크기 로그
|
||||
console.log("🎨 [RealtimePreview] 렌더링", {
|
||||
componentId: id,
|
||||
size,
|
||||
position,
|
||||
});
|
||||
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
|
||||
const [actualHeight, setActualHeight] = useState<number | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -465,17 +458,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onClick?.(e);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// 디자인 모드에서만 드래그 시작 (캔버스 내 이동용)
|
||||
if (isDesignMode && onDragStart) {
|
||||
e.stopPropagation();
|
||||
// MouseEvent를 그대로 전달
|
||||
onDragStart(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
// HTML5 Drag API (팔레트에서 캔버스로 드래그용)
|
||||
e.stopPropagation();
|
||||
onDragStart?.(e);
|
||||
};
|
||||
|
|
@ -490,9 +473,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
className="absolute cursor-pointer"
|
||||
style={{ ...componentStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
onMouseDown={isDesignMode ? handleMouseDown : undefined}
|
||||
draggable={!isDesignMode} // 디자인 모드가 아닐 때만 draggable (팔레트용)
|
||||
onDragStart={!isDesignMode ? handleDragStart : undefined}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -264,9 +264,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
height: getHeight(),
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
...componentStyle,
|
||||
// 🔥 중요: componentStyle.width를 덮어쓰기 위해 다시 설정
|
||||
width: getWidth(), // size.width 기반 픽셀 값으로 강제
|
||||
height: getHeight(), // size.height 기반 픽셀 값으로 강제
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,13 @@ import {
|
|||
snapToGrid,
|
||||
snapSizeToGrid,
|
||||
generateGridLines,
|
||||
updateSizeFromGridColumns,
|
||||
adjustGridColumnsFromSize,
|
||||
alignGroupChildrenToGrid,
|
||||
calculateOptimalGroupSize,
|
||||
normalizeGroupChildPositions,
|
||||
calculateWidthFromColumns,
|
||||
GridSettings as GridUtilSettings,
|
||||
} from "@/lib/utils/gridUtils";
|
||||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -103,8 +107,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const [layout, setLayout] = useState<LayoutData>({
|
||||
components: [],
|
||||
gridSettings: {
|
||||
snapToGrid: true, // 격자 스냅 ON
|
||||
showGrid: false, // 격자 표시 OFF
|
||||
columns: 12,
|
||||
gap: 16,
|
||||
padding: 0,
|
||||
snapToGrid: true,
|
||||
showGrid: false, // 기본값 false로 변경
|
||||
gridColor: "#d1d5db",
|
||||
gridOpacity: 0.5,
|
||||
},
|
||||
|
|
@ -533,31 +540,107 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
gridInfo &&
|
||||
newComp.type !== "group"
|
||||
) {
|
||||
// 🔥 10px 고정 격자로 스냅
|
||||
const currentGridInfo = calculateGridInfo(
|
||||
screenResolution.width,
|
||||
screenResolution.height,
|
||||
prevLayout.gridSettings,
|
||||
// 현재 해상도에 맞는 격자 정보로 스냅 적용
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: prevLayout.gridSettings.columns,
|
||||
gap: prevLayout.gridSettings.gap,
|
||||
padding: prevLayout.gridSettings.padding,
|
||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
const snappedSize = snapSizeToGrid(
|
||||
newComp.size,
|
||||
currentGridInfo,
|
||||
prevLayout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, prevLayout.gridSettings);
|
||||
newComp.size = snappedSize;
|
||||
|
||||
// 크기 변경 시 gridColumns도 자동 조정
|
||||
const adjustedColumns = adjustGridColumnsFromSize(
|
||||
newComp,
|
||||
currentGridInfo,
|
||||
prevLayout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
if (newComp.gridColumns !== adjustedColumns) {
|
||||
newComp.gridColumns = adjustedColumns;
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ gridColumns 로직 제거: 10px 고정 격자에서는 불필요
|
||||
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
|
||||
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: prevLayout.gridSettings.columns,
|
||||
gap: prevLayout.gridSettings.gap,
|
||||
padding: prevLayout.gridSettings.padding,
|
||||
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
|
||||
// gridColumns에 맞는 정확한 너비 계산
|
||||
const newWidth = calculateWidthFromColumns(
|
||||
newComp.gridColumns,
|
||||
currentGridInfo,
|
||||
prevLayout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
newComp.size = {
|
||||
...newComp.size,
|
||||
width: newWidth,
|
||||
};
|
||||
}
|
||||
|
||||
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
|
||||
if (
|
||||
(path === "position.x" || path === "position.y" || path === "position") &&
|
||||
layout.gridSettings?.snapToGrid
|
||||
) {
|
||||
// 🔥 10px 고정 격자
|
||||
const currentGridInfo = calculateGridInfo(
|
||||
screenResolution.width,
|
||||
screenResolution.height,
|
||||
layout.gridSettings,
|
||||
);
|
||||
const snappedPosition = snapToGrid(newComp.position, currentGridInfo, layout.gridSettings);
|
||||
newComp.position = snappedPosition;
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
});
|
||||
|
||||
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
|
||||
if (newComp.parentId && currentGridInfo) {
|
||||
const { columnWidth } = currentGridInfo;
|
||||
const { gap } = layout.gridSettings;
|
||||
|
||||
// 그룹 내부 패딩 고려한 격자 정렬
|
||||
const padding = 16;
|
||||
const effectiveX = newComp.position.x - padding;
|
||||
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
|
||||
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
|
||||
|
||||
// Y 좌표는 10px 단위로 스냅
|
||||
const effectiveY = newComp.position.y - padding;
|
||||
const rowIndex = Math.round(effectiveY / 10);
|
||||
const snappedY = padding + rowIndex * 10;
|
||||
|
||||
// 크기도 외부 격자와 동일하게 스냅
|
||||
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
|
||||
const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth));
|
||||
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
|
||||
// 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거)
|
||||
const snappedHeight = Math.max(10, newComp.size.height);
|
||||
|
||||
newComp.position = {
|
||||
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
|
||||
y: Math.max(padding, snappedY),
|
||||
z: newComp.position.z || 1,
|
||||
};
|
||||
|
||||
newComp.size = {
|
||||
width: snappedWidth,
|
||||
height: snappedHeight,
|
||||
};
|
||||
} else if (newComp.type !== "group") {
|
||||
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
|
||||
const snappedPosition = snapToGrid(
|
||||
newComp.position,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
newComp.position = snappedPosition;
|
||||
}
|
||||
}
|
||||
|
||||
return newComp;
|
||||
|
|
@ -752,7 +835,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
||||
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
|
||||
|
||||
|
||||
// 🔍 이미지 타입 디버깅
|
||||
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
|
||||
// console.log("🖼️ 이미지 컬럼 발견:", {
|
||||
|
|
@ -762,7 +845,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// rawData: col,
|
||||
// });
|
||||
// }
|
||||
|
||||
|
||||
return {
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
|
|
@ -820,21 +903,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
|
||||
const convertedComponents = convertLayoutComponents(layoutToUse.components);
|
||||
|
||||
// 🔥 10px 고정 격자 시스템으로 자동 마이그레이션
|
||||
// 이전 columns, gap, padding 설정을 제거하고 새 시스템으로 변환
|
||||
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
||||
const layoutWithDefaultGrid = {
|
||||
...layoutToUse,
|
||||
components: convertedComponents, // 변환된 컴포넌트 사용
|
||||
gridSettings: {
|
||||
// 🗑️ 제거: columns, gap, padding (더 이상 사용하지 않음)
|
||||
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
|
||||
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
|
||||
padding: 0, // padding은 항상 0으로 강제
|
||||
snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선
|
||||
showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선
|
||||
gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db",
|
||||
gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("✅ 격자 설정 로드 (10px 고정):", layoutWithDefaultGrid.gridSettings);
|
||||
|
||||
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
|
||||
if (layoutToUse.screenResolution) {
|
||||
|
|
@ -992,12 +1074,51 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
}, [MIN_ZOOM, MAX_ZOOM]);
|
||||
|
||||
// 격자 설정 업데이트 (10px 고정 격자 - 자동 스냅 제거)
|
||||
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
|
||||
const updateGridSettings = useCallback(
|
||||
(newGridSettings: GridSettings) => {
|
||||
// 단순히 격자 설정만 업데이트 (컴포넌트 자동 이동 없음)
|
||||
const newLayout = { ...layout, gridSettings: newGridSettings };
|
||||
|
||||
|
||||
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
|
||||
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
|
||||
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
|
||||
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: newGridSettings.columns,
|
||||
gap: newGridSettings.gap,
|
||||
padding: newGridSettings.padding,
|
||||
snapToGrid: newGridSettings.snapToGrid || false,
|
||||
});
|
||||
|
||||
const gridUtilSettings = {
|
||||
columns: newGridSettings.columns,
|
||||
gap: newGridSettings.gap,
|
||||
padding: newGridSettings.padding,
|
||||
snapToGrid: newGridSettings.snapToGrid,
|
||||
};
|
||||
|
||||
const adjustedComponents = layout.components.map((comp) => {
|
||||
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
|
||||
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
|
||||
|
||||
// gridColumns가 없거나 범위를 벗어나면 자동 조정
|
||||
let adjustedGridColumns = comp.gridColumns;
|
||||
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
|
||||
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
|
||||
}
|
||||
|
||||
return {
|
||||
...comp,
|
||||
position: snappedPosition,
|
||||
size: snappedSize,
|
||||
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
|
||||
};
|
||||
});
|
||||
|
||||
newLayout.components = adjustedComponents;
|
||||
// console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
|
||||
// console.log("새로운 격자 정보:", newGridInfo);
|
||||
}
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
},
|
||||
|
|
@ -1094,13 +1215,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
|
||||
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
|
||||
|
||||
// gridColumns 재계산
|
||||
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
|
||||
|
||||
return {
|
||||
...comp,
|
||||
position: snappedPosition,
|
||||
size: snappedSize,
|
||||
gridColumns: adjustedGridColumns,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("🧲 격자 스냅 적용 완료");
|
||||
}
|
||||
|
||||
const updatedLayout = {
|
||||
|
|
@ -1159,10 +1285,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings);
|
||||
const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings);
|
||||
|
||||
// gridColumns가 없거나 범위를 벗어나면 자동 조정
|
||||
let adjustedGridColumns = comp.gridColumns;
|
||||
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) {
|
||||
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings);
|
||||
}
|
||||
|
||||
return {
|
||||
...comp,
|
||||
position: snappedPosition,
|
||||
size: snappedSize,
|
||||
gridColumns: adjustedGridColumns,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -1321,8 +1454,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
: { x: absoluteX, y: absoluteY, z: 1 };
|
||||
|
||||
if (templateComp.type === "container") {
|
||||
// 🔥 10px 고정 격자: 기본 너비 사용
|
||||
const calculatedSize = { width: 400, height: templateComp.size.height };
|
||||
// 그리드 컬럼 기반 크기 계산
|
||||
const gridColumns =
|
||||
typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼
|
||||
|
||||
const calculatedSize =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? (() => {
|
||||
const newWidth = calculateWidthFromColumns(
|
||||
gridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
return {
|
||||
width: newWidth,
|
||||
height: templateComp.size.height,
|
||||
};
|
||||
})()
|
||||
: { width: 400, height: templateComp.size.height }; // 폴백 크기
|
||||
|
||||
return {
|
||||
id: componentId,
|
||||
|
|
@ -1346,11 +1495,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 데이터 테이블 컴포넌트 생성
|
||||
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
|
||||
|
||||
// 🔥 10px 고정 격자: 기본 크기 사용
|
||||
const calculatedSize = {
|
||||
width: 800, // 데이터 테이블 기본 너비
|
||||
height: templateComp.size.height,
|
||||
};
|
||||
// gridColumns에 맞는 크기 계산
|
||||
const calculatedSize =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? (() => {
|
||||
const newWidth = calculateWidthFromColumns(
|
||||
gridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
return {
|
||||
width: newWidth,
|
||||
height: templateComp.size.height, // 높이는 템플릿 값 유지
|
||||
};
|
||||
})()
|
||||
: templateComp.size;
|
||||
|
||||
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
|
||||
gridColumns,
|
||||
|
|
@ -1415,11 +1574,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 파일 첨부 컴포넌트 생성
|
||||
const gridColumns = 6; // 기본값: 6컬럼
|
||||
|
||||
// 🔥 10px 고정 격자
|
||||
const calculatedSize = {
|
||||
width: 400,
|
||||
height: templateComp.size.height,
|
||||
};
|
||||
const calculatedSize =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? (() => {
|
||||
const newWidth = calculateWidthFromColumns(
|
||||
gridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
return {
|
||||
width: newWidth,
|
||||
height: templateComp.size.height,
|
||||
};
|
||||
})()
|
||||
: templateComp.size;
|
||||
|
||||
return {
|
||||
id: componentId,
|
||||
|
|
@ -1457,11 +1625,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 영역 컴포넌트 생성
|
||||
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
|
||||
|
||||
// 🔥 10px 고정 격자
|
||||
const calculatedSize = {
|
||||
width: 600, // 영역 기본 너비
|
||||
height: templateComp.size.height,
|
||||
};
|
||||
const calculatedSize =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? (() => {
|
||||
const newWidth = calculateWidthFromColumns(
|
||||
gridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
return {
|
||||
width: newWidth,
|
||||
height: templateComp.size.height,
|
||||
};
|
||||
})()
|
||||
: templateComp.size;
|
||||
|
||||
return {
|
||||
id: componentId,
|
||||
|
|
@ -1583,7 +1760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const widgetSize =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? {
|
||||
width: 200,
|
||||
width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings),
|
||||
height: templateComp.size.height,
|
||||
}
|
||||
: templateComp.size;
|
||||
|
|
@ -1788,16 +1965,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산
|
||||
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center)
|
||||
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음
|
||||
|
||||
|
||||
// 실제 캔버스 논리적 크기
|
||||
const canvasLogicalWidth = screenResolution.width;
|
||||
|
||||
|
||||
// 화면상 캔버스 실제 크기 (스케일 적용 후)
|
||||
const canvasVisualWidth = canvasLogicalWidth * zoomLevel;
|
||||
|
||||
|
||||
// 중앙 정렬로 인한 왼쪽 오프셋 계산
|
||||
// rect.left는 이미 중앙 정렬된 위치를 반영하고 있음
|
||||
|
||||
|
||||
// 마우스의 캔버스 내 상대 위치 (스케일 보정)
|
||||
const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel;
|
||||
const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel;
|
||||
|
|
@ -1954,9 +2131,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
}
|
||||
|
||||
// 🗑️ 10px 고정 격자: gridColumns 로직 제거
|
||||
// 기본 크기만 사용
|
||||
componentSize = component.defaultSize;
|
||||
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
|
||||
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||||
// gridColumns에 맞는 정확한 너비 계산
|
||||
const calculatedWidth = calculateWidthFromColumns(
|
||||
gridColumns,
|
||||
gridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
);
|
||||
|
||||
// 컴포넌트별 최소 크기 보장
|
||||
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
|
||||
|
||||
componentSize = {
|
||||
...component.defaultSize,
|
||||
width: Math.max(calculatedWidth, minWidth),
|
||||
};
|
||||
}
|
||||
|
||||
console.log("🎨 최종 컴포넌트 크기:", {
|
||||
componentId: component.id,
|
||||
|
|
@ -2056,12 +2247,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
// console.log("🎯 드롭 이벤트:", { dragData });
|
||||
if (!dragData) {
|
||||
// console.log("❌ 드래그 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(dragData);
|
||||
// console.log("📋 파싱된 데이터:", parsedData);
|
||||
|
||||
// 템플릿 드래그인 경우
|
||||
if (parsedData.type === "template") {
|
||||
|
|
@ -2115,8 +2309,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
} else if (type === "column") {
|
||||
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
||||
// 🔥 10px 고정 격자 시스템: 간단한 기본 너비 사용
|
||||
const defaultWidth = 200; // 기본 너비 200px
|
||||
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
})
|
||||
: null;
|
||||
|
||||
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
|
||||
const defaultWidth =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||
: 200;
|
||||
|
||||
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
|
||||
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
|
||||
gridSettings: layout.gridSettings,
|
||||
currentGridInfo: currentGridInfo
|
||||
? {
|
||||
columnWidth: currentGridInfo.columnWidth.toFixed(2),
|
||||
totalWidth: currentGridInfo.totalWidth,
|
||||
}
|
||||
: null,
|
||||
defaultWidth: defaultWidth.toFixed(2),
|
||||
snapToGrid: layout.gridSettings?.snapToGrid,
|
||||
});
|
||||
|
||||
// 웹타입별 기본 그리드 컬럼 수 계산
|
||||
const getDefaultGridColumns = (widgetType: string): number => {
|
||||
|
|
@ -2155,6 +2375,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
|
||||
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
|
||||
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
|
||||
return defaultColumns;
|
||||
};
|
||||
|
||||
|
|
@ -2326,9 +2547,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 웹타입별 적절한 gridColumns 계산
|
||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
||||
|
||||
// 🔥 10px 고정 격자: 간단한 너비 계산
|
||||
const componentWidth = defaultWidth;
|
||||
// gridColumns에 맞는 실제 너비 계산
|
||||
const componentWidth =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? calculateWidthFromColumns(
|
||||
calculatedGridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
)
|
||||
: defaultWidth;
|
||||
|
||||
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
|
||||
widgetType: column.widgetType,
|
||||
calculatedGridColumns,
|
||||
componentWidth,
|
||||
defaultWidth,
|
||||
});
|
||||
|
||||
newComponent = {
|
||||
id: generateComponentId(),
|
||||
|
|
@ -2349,7 +2583,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
codeCategory: column.codeCategory,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: false, // 라벨 숨김 (placeholder 사용)
|
||||
labelDisplay: false, // 라벨 숨김
|
||||
labelFontSize: "12px",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -2361,7 +2595,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시
|
||||
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -2380,9 +2614,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 웹타입별 적절한 gridColumns 계산
|
||||
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
|
||||
|
||||
// 🔥 10px 고정 격자: 간단한 너비 계산
|
||||
const componentWidth = defaultWidth;
|
||||
// gridColumns에 맞는 실제 너비 계산
|
||||
const componentWidth =
|
||||
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||
? calculateWidthFromColumns(
|
||||
calculatedGridColumns,
|
||||
currentGridInfo,
|
||||
layout.gridSettings as GridUtilSettings,
|
||||
)
|
||||
: defaultWidth;
|
||||
|
||||
console.log("🎯 캔버스 컴포넌트 생성:", {
|
||||
widgetType: column.widgetType,
|
||||
calculatedGridColumns,
|
||||
componentWidth,
|
||||
defaultWidth,
|
||||
});
|
||||
|
||||
// 🔍 이미지 타입 드래그앤드롭 디버깅
|
||||
// if (column.widgetType === "image") {
|
||||
|
|
@ -2412,7 +2659,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
codeCategory: column.codeCategory,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: false, // 라벨 숨김 (placeholder 사용)
|
||||
labelDisplay: false, // 라벨 숨김
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#000000", // 순수한 검정
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -2424,7 +2671,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
webType: column.widgetType, // 원본 웹타입 보존
|
||||
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||
...getDefaultWebTypeConfig(column.widgetType),
|
||||
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시
|
||||
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -2456,6 +2703,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
|
||||
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
|
||||
|
||||
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
|
||||
type: newComponent.type,
|
||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
||||
snappedPosition: newComponent.position,
|
||||
snappedSize: newComponent.size,
|
||||
columnWidth: currentGridInfo.columnWidth,
|
||||
});
|
||||
}
|
||||
|
||||
if (newComponent.type === "group") {
|
||||
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
|
||||
type: newComponent.type,
|
||||
position: newComponent.position,
|
||||
size: newComponent.size,
|
||||
});
|
||||
}
|
||||
|
||||
const newLayout = {
|
||||
|
|
@ -2629,11 +2891,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
componentsToMove = [...componentsToMove, ...additionalComponents];
|
||||
}
|
||||
|
||||
const finalGrabOffset = {
|
||||
x: relativeMouseX - component.position.x,
|
||||
y: relativeMouseY - component.position.y,
|
||||
};
|
||||
|
||||
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
||||
console.log("마우스 위치 (줌 보정):", {
|
||||
zoomLevel,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
rectLeft: rect.left,
|
||||
rectTop: rect.top,
|
||||
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||
componentX: component.position.x,
|
||||
componentY: component.position.y,
|
||||
grabOffsetX: relativeMouseX - component.position.x,
|
||||
grabOffsetY: relativeMouseY - component.position.y,
|
||||
});
|
||||
|
||||
console.log("🚀 드래그 시작:", {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
initialPosition: { x: component.position.x, y: component.position.y },
|
||||
});
|
||||
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준)
|
||||
|
|
@ -2648,7 +2926,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
y: component.position.y,
|
||||
z: (component.position as Position).z || 1,
|
||||
},
|
||||
grabOffset: finalGrabOffset,
|
||||
grabOffset: {
|
||||
x: relativeMouseX - component.position.x,
|
||||
y: relativeMouseY - component.position.y,
|
||||
},
|
||||
justFinishedDrag: false,
|
||||
});
|
||||
},
|
||||
|
|
@ -2676,24 +2957,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const rawX = relativeMouseX - dragState.grabOffset.x;
|
||||
const rawY = relativeMouseY - dragState.grabOffset.y;
|
||||
|
||||
// 🔥 경계 제한 로직 제거: 컴포넌트가 화면을 벗어나도 되게 함
|
||||
// 이유:
|
||||
// 1. 큰 컴포넌트(884px)를 작은 영역(16px)에만 제한하는 것은 사용성 문제
|
||||
// 2. 사용자가 자유롭게 배치할 수 있어야 함
|
||||
// 3. 최소 위치만 0 이상으로 제한 (음수 좌표 방지)
|
||||
|
||||
const newPosition = {
|
||||
x: Math.max(0, rawX),
|
||||
y: Math.max(0, rawY),
|
||||
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
|
||||
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
|
||||
z: (dragState.draggedComponent.position as Position).z || 1,
|
||||
};
|
||||
|
||||
// 드래그 상태 업데이트
|
||||
console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", {
|
||||
zoomLevel,
|
||||
draggedComponentId: dragState.draggedComponent.id,
|
||||
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
|
||||
oldPosition: dragState.currentPosition,
|
||||
newPosition: newPosition,
|
||||
});
|
||||
|
||||
setDragState((prev) => {
|
||||
const newState = {
|
||||
...prev,
|
||||
currentPosition: { ...newPosition }, // 새로운 객체 생성
|
||||
};
|
||||
console.log("🔄 ScreenDesigner dragState 업데이트:", {
|
||||
prevPosition: prev.currentPosition,
|
||||
newPosition: newState.currentPosition,
|
||||
stateChanged:
|
||||
prev.currentPosition.x !== newState.currentPosition.x ||
|
||||
prev.currentPosition.y !== newState.currentPosition.y,
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
|
||||
|
|
@ -2711,8 +3002,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
|
||||
let finalPosition = dragState.currentPosition;
|
||||
|
||||
// 🔥 10px 고정 격자 시스템: calculateGridInfo는 columns, gap, padding을 무시함
|
||||
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, layout.gridSettings);
|
||||
// 현재 해상도에 맞는 격자 정보 계산
|
||||
const currentGridInfo = layout.gridSettings
|
||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
})
|
||||
: null;
|
||||
|
||||
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
|
||||
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
||||
|
|
@ -2723,9 +3021,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
z: dragState.currentPosition.z ?? 1,
|
||||
},
|
||||
currentGridInfo,
|
||||
layout.gridSettings,
|
||||
{
|
||||
columns: layout.gridSettings.columns,
|
||||
gap: layout.gridSettings.gap,
|
||||
padding: layout.gridSettings.padding,
|
||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||
},
|
||||
);
|
||||
|
||||
console.log("🎯 격자 스냅 적용됨:", {
|
||||
componentType: draggedComponent?.type,
|
||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
||||
originalPosition: dragState.currentPosition,
|
||||
snappedPosition: finalPosition,
|
||||
columnWidth: currentGridInfo.columnWidth,
|
||||
});
|
||||
}
|
||||
|
||||
// 스냅으로 인한 추가 이동 거리 계산
|
||||
|
|
@ -2790,6 +3100,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
height: snappedHeight,
|
||||
};
|
||||
|
||||
console.log("🎯 드래그 종료 시 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
|
||||
componentId: comp.id,
|
||||
parentId: comp.parentId,
|
||||
beforeSnap: {
|
||||
x: originalComponent.position.x + totalDeltaX,
|
||||
y: originalComponent.position.y + totalDeltaY,
|
||||
},
|
||||
calculation: {
|
||||
effectiveX,
|
||||
effectiveY,
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
columnWidth,
|
||||
fullColumnWidth,
|
||||
widthInColumns,
|
||||
gap: gap || 16,
|
||||
padding,
|
||||
},
|
||||
afterSnap: newPosition,
|
||||
afterSizeSnap: newSize,
|
||||
});
|
||||
|
||||
return {
|
||||
...comp,
|
||||
position: newPosition as Position,
|
||||
|
|
@ -2812,6 +3144,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) {
|
||||
const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id);
|
||||
if (updatedSelectedComponent) {
|
||||
console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", {
|
||||
componentId: selectedComponent.id,
|
||||
oldPosition: selectedComponent.position,
|
||||
newPosition: updatedSelectedComponent.position,
|
||||
});
|
||||
setSelectedComponent(updatedSelectedComponent);
|
||||
}
|
||||
}
|
||||
|
|
@ -4029,7 +4366,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
return (
|
||||
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
|
|
|
|||
|
|
@ -1,79 +1,335 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Grid3X3 } from "lucide-react";
|
||||
import { GridSettings } from "@/types/screen-management";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
|
||||
import { GridSettings, ScreenResolution } from "@/types/screen";
|
||||
import { calculateGridInfo } from "@/lib/utils/gridUtils";
|
||||
|
||||
interface GridPanelProps {
|
||||
gridSettings: GridSettings;
|
||||
onGridSettingsChange: (settings: GridSettings) => void;
|
||||
onResetGrid: () => void;
|
||||
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
|
||||
screenResolution?: ScreenResolution; // 해상도 정보 추가
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 설정 패널 (10px 고정 격자)
|
||||
*
|
||||
* 사용자 설정:
|
||||
* - 격자 표시 ON/OFF
|
||||
* - 격자 스냅 ON/OFF
|
||||
*
|
||||
* 자동 설정 (변경 불가):
|
||||
* - 격자 크기: 10px 고정
|
||||
* - 격자 간격: 10px 고정
|
||||
*/
|
||||
export function GridPanel({ gridSettings, onGridSettingsChange }: GridPanelProps) {
|
||||
const updateSetting = <K extends keyof GridSettings>(key: K, value: GridSettings[K]) => {
|
||||
export const GridPanel: React.FC<GridPanelProps> = ({
|
||||
gridSettings,
|
||||
onGridSettingsChange,
|
||||
onResetGrid,
|
||||
onForceGridUpdate,
|
||||
screenResolution,
|
||||
}) => {
|
||||
const updateSetting = (key: keyof GridSettings, value: any) => {
|
||||
onGridSettingsChange({
|
||||
...gridSettings,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// 최대 컬럼 수 계산 (최소 컬럼 너비 30px 기준)
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = screenResolution
|
||||
? Math.floor((screenResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
// 실제 격자 정보 계산
|
||||
const actualGridInfo = screenResolution
|
||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||
columns: gridSettings.columns,
|
||||
gap: gridSettings.gap,
|
||||
padding: gridSettings.padding,
|
||||
snapToGrid: gridSettings.snapToGrid || false,
|
||||
})
|
||||
: null;
|
||||
|
||||
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
|
||||
const actualColumns = gridSettings.columns;
|
||||
|
||||
// 컬럼이 너무 작은지 확인
|
||||
const isColumnsTooSmall =
|
||||
screenResolution && actualGridInfo
|
||||
? actualGridInfo.columnWidth < MIN_COLUMN_WIDTH
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="space-y-1 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
<CardTitle className="text-base">격자 설정</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 격자 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showGrid" className="flex items-center gap-2 text-sm font-medium">
|
||||
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
|
||||
격자 표시
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={gridSettings.showGrid ?? false}
|
||||
onCheckedChange={(checked) => updateSetting("showGrid", checked as boolean)}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3X3 className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">격자 설정</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onForceGridUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onForceGridUpdate}
|
||||
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
|
||||
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
재정렬
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 격자 스냅 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="snapToGrid" className="flex items-center gap-2 text-sm font-medium">
|
||||
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
|
||||
격자 스냅
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="snapToGrid"
|
||||
checked={gridSettings.snapToGrid}
|
||||
onCheckedChange={(checked) => updateSetting("snapToGrid", checked as boolean)}
|
||||
/>
|
||||
{/* 주요 토글들 */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{gridSettings.showGrid ? (
|
||||
<Eye className="text-primary h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Label htmlFor="showGrid" className="text-xs font-medium">
|
||||
격자 표시
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={gridSettings.showGrid}
|
||||
onCheckedChange={(checked) => updateSetting("showGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="text-primary h-3.5 w-3.5" />
|
||||
<Label htmlFor="snapToGrid" className="text-xs font-medium">
|
||||
격자 스냅
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="snapToGrid"
|
||||
checked={gridSettings.snapToGrid}
|
||||
onCheckedChange={(checked) => updateSetting("snapToGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* 격자 구조 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold">격자 구조</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="columns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={safeMaxColumns}
|
||||
value={gridSettings.columns}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
|
||||
updateSetting("columns", value);
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">/ {safeMaxColumns}</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="columns-slider"
|
||||
min={1}
|
||||
max={safeMaxColumns}
|
||||
step={1}
|
||||
value={[gridSettings.columns]}
|
||||
onValueChange={([value]) => updateSetting("columns", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>1열</span>
|
||||
<span>{safeMaxColumns}열</span>
|
||||
</div>
|
||||
{isColumnsTooSmall && (
|
||||
<p className="text-xs text-amber-600">
|
||||
⚠️ 컬럼 너비가 너무 작습니다 (최소 {MIN_COLUMN_WIDTH}px 권장)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap" className="text-xs font-medium">
|
||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="gap"
|
||||
min={0}
|
||||
max={40}
|
||||
step={2}
|
||||
value={[gridSettings.gap]}
|
||||
onValueChange={([value]) => updateSetting("gap", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>0px</span>
|
||||
<span>40px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding" className="text-xs font-medium">
|
||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="padding"
|
||||
min={0}
|
||||
max={60}
|
||||
step={4}
|
||||
value={[gridSettings.padding]}
|
||||
onValueChange={([value]) => updateSetting("padding", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>0px</span>
|
||||
<span>60px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 격자 정보 (읽기 전용) */}
|
||||
<div className="mt-4 rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-1">🔧 격자 시스템</p>
|
||||
<ul className="space-y-0.5">
|
||||
<li>• 격자 크기: <span className="font-semibold">10px 고정</span></li>
|
||||
<li>• 컴포넌트는 10px 단위로 배치됩니다</li>
|
||||
<li>• 격자 스냅을 끄면 자유롭게 배치 가능</li>
|
||||
</ul>
|
||||
<Separator />
|
||||
|
||||
{/* 격자 스타일 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">격자 스타일</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gridColor" className="text-sm font-medium">
|
||||
격자 색상
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Input
|
||||
id="gridColor"
|
||||
type="color"
|
||||
value={gridSettings.gridColor || "#d1d5db"}
|
||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||
className="h-8 w-12 rounded border p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={gridSettings.gridColor || "#d1d5db"}
|
||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
||||
placeholder="#d1d5db"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
|
||||
격자 투명도: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
|
||||
</Label>
|
||||
<Slider
|
||||
id="gridOpacity"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={[gridSettings.gridOpacity || 0.5]}
|
||||
onValueChange={([value]) => updateSetting("gridOpacity", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<span>10%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">미리보기</h4>
|
||||
|
||||
<div
|
||||
className="rounded-md border border-gray-200 bg-white p-4"
|
||||
style={{
|
||||
backgroundImage: gridSettings.showGrid
|
||||
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
|
||||
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
|
||||
: "none",
|
||||
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
|
||||
opacity: gridSettings.gridOpacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
|
||||
<span className="text-primary text-xs">컴포넌트 예시</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-muted-foreground text-xs">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
||||
|
||||
{/* 해상도 및 격자 정보 */}
|
||||
{screenResolution && actualGridInfo && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">격자 정보</h4>
|
||||
|
||||
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">해상도:</span>
|
||||
<span className="font-mono">
|
||||
{screenResolution.width} × {screenResolution.height}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">컬럼 너비:</span>
|
||||
<span className={`font-mono ${isColumnsTooSmall ? "text-destructive" : "text-gray-900"}`}>
|
||||
{actualGridInfo.columnWidth.toFixed(1)}px
|
||||
{isColumnsTooSmall && " (너무 작음)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">사용 가능 너비:</span>
|
||||
<span className="font-mono">
|
||||
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isColumnsTooSmall && (
|
||||
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
|
||||
💡 컬럼이 너무 작습니다. 컬럼 수를 줄이거나 간격을 줄여보세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default GridPanel;
|
||||
|
|
|
|||
|
|
@ -53,16 +53,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
onDragStart,
|
||||
placedColumns = new Set(),
|
||||
}) => {
|
||||
// 숨길 기본 컬럼 목록 (id, created_date, updated_date, writer, company_code)
|
||||
const hiddenColumns = new Set(['id', 'created_date', 'updated_date', 'writer', 'company_code']);
|
||||
|
||||
// 이미 배치된 컬럼 + 기본 컬럼을 제외한 테이블 정보 생성
|
||||
// 시스템 컬럼 목록 (숨김 처리)
|
||||
const systemColumns = new Set([
|
||||
'id',
|
||||
'created_date',
|
||||
'updated_date',
|
||||
'writer',
|
||||
'company_code'
|
||||
]);
|
||||
|
||||
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
|
||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||
...table,
|
||||
columns: table.columns.filter((col) => {
|
||||
const columnKey = `${table.tableName}.${col.columnName}`;
|
||||
// 기본 컬럼 또는 이미 배치된 컬럼은 제외
|
||||
return !hiddenColumns.has(col.columnName) && !placedColumns.has(columnKey);
|
||||
// 시스템 컬럼이거나 이미 배치된 컬럼은 제외
|
||||
return !systemColumns.has(col.columnName.toLowerCase()) && !placedColumns.has(columnKey);
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -125,23 +125,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}, [selectedComponent?.size?.height, selectedComponent?.id]);
|
||||
|
||||
// 🔥 훅은 항상 최상단에 (early return 이전)
|
||||
// 크기 입력 필드용 로컬 상태
|
||||
const [localSize, setLocalSize] = useState({
|
||||
width: selectedComponent?.size?.width || 100,
|
||||
height: selectedComponent?.size?.height || 40,
|
||||
});
|
||||
|
||||
// 선택된 컴포넌트가 변경되면 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent) {
|
||||
setLocalSize({
|
||||
width: selectedComponent.size?.width || 100,
|
||||
height: selectedComponent.size?.height || 40,
|
||||
});
|
||||
}
|
||||
}, [selectedComponent?.id, selectedComponent?.size?.width, selectedComponent?.size?.height]);
|
||||
|
||||
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||
const updateGridSetting = (key: string, value: any) => {
|
||||
if (onGridSettingsChange && gridSettings) {
|
||||
|
|
@ -152,10 +135,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 격자 설정 렌더링 (10px 고정 격자)
|
||||
// 격자 설정 렌더링 (early return 이전에 정의)
|
||||
const renderGridSettings = () => {
|
||||
if (!gridSettings || !onGridSettingsChange) return null;
|
||||
|
||||
// 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = currentResolution
|
||||
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -164,7 +154,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 격자 표시 */}
|
||||
{/* 토글들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{gridSettings.showGrid ? (
|
||||
|
|
@ -178,12 +168,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={gridSettings.showGrid ?? false}
|
||||
checked={gridSettings.showGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 격자 스냅 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="text-primary h-3 w-3" />
|
||||
|
|
@ -198,14 +187,65 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 격자 정보 (읽기 전용) */}
|
||||
<div className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground">
|
||||
<p className="font-medium mb-0.5">🔧 격자 시스템</p>
|
||||
<ul className="space-y-0.5">
|
||||
<li>• 격자 크기: <span className="font-semibold">10px 고정</span></li>
|
||||
<li>• 컴포넌트는 10px 단위로 배치됩니다</li>
|
||||
<li>• 격자 스냅을 끄면 자유롭게 배치 가능</li>
|
||||
</ul>
|
||||
{/* 컬럼 수 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="columns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={safeMaxColumns}
|
||||
step="1"
|
||||
value={gridSettings.columns}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
|
||||
updateGridSetting("columns", value);
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder={`1~${safeMaxColumns}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
최대 {safeMaxColumns}개까지 설정 가능 (최소 컬럼 너비 {MIN_COLUMN_WIDTH}px)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 간격 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="gap" className="text-xs font-medium">
|
||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="gap"
|
||||
min={0}
|
||||
max={40}
|
||||
step={2}
|
||||
value={[gridSettings.gap]}
|
||||
onValueChange={([value]) => updateGridSetting("gap", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 여백 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="padding" className="text-xs font-medium">
|
||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="padding"
|
||||
min={0}
|
||||
max={60}
|
||||
step={4}
|
||||
value={[gridSettings.padding]}
|
||||
onValueChange={([value]) => updateGridSetting("padding", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -415,90 +455,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Z-Index */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Z-Index</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 크기 (너비/높이) */}
|
||||
{/* Grid Columns + Z-Index (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">차지 컬럼 수</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={gridSettings?.columns || 12}
|
||||
step="1"
|
||||
value={(selectedComponent as any).gridColumns || 1}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
const maxColumns = gridSettings?.columns || 12;
|
||||
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
|
||||
handleUpdate("gridColumns", value);
|
||||
|
||||
// width를 퍼센트로 계산하여 업데이트
|
||||
const widthPercent = (value / maxColumns) * 100;
|
||||
handleUpdate("style.width", `${widthPercent}%`);
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
|
||||
/{gridSettings?.columns || 12}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Label className="text-xs">Z-Index</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={localSize.width}
|
||||
onChange={(e) => {
|
||||
// 입력 중에는 로컬 상태만 업데이트
|
||||
const value = e.target.value === "" ? "" : parseInt(e.target.value);
|
||||
setLocalSize((prev) => ({ ...prev, width: value as number }));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 포커스 아웃 시 실제 컴포넌트 업데이트
|
||||
const rawValue = e.target.value;
|
||||
const parsedValue = parseInt(rawValue);
|
||||
const newWidth = Math.max(10, parsedValue || 10);
|
||||
|
||||
// 로컬 상태도 최종값으로 업데이트
|
||||
setLocalSize((prev) => ({ ...prev, width: newWidth }));
|
||||
|
||||
// size.width 경로로 업데이트 (격자 스냅 적용됨)
|
||||
handleUpdate("size.width", newWidth);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter 키로도 즉시 적용
|
||||
if (e.key === "Enter") {
|
||||
const newWidth = Math.max(10, parseInt((e.target as HTMLInputElement).value) || 10);
|
||||
setLocalSize((prev) => ({ ...prev, width: newWidth }));
|
||||
handleUpdate("size.width", newWidth);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={localSize.height}
|
||||
onChange={(e) => {
|
||||
// 입력 중에는 로컬 상태만 업데이트
|
||||
const value = e.target.value === "" ? "" : parseInt(e.target.value);
|
||||
setLocalSize((prev) => ({ ...prev, height: value as number }));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 포커스 아웃 시 실제 컴포넌트 업데이트
|
||||
const rawValue = e.target.value;
|
||||
const parsedValue = parseInt(rawValue);
|
||||
const newHeight = Math.max(10, parsedValue || 10);
|
||||
|
||||
// 로컬 상태도 최종값으로 업데이트
|
||||
setLocalSize((prev) => ({ ...prev, height: newHeight }));
|
||||
|
||||
// size.height 경로로 업데이트 (격자 스냅 적용됨)
|
||||
handleUpdate("size.height", newHeight);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter 키로도 즉시 적용
|
||||
if (e.key === "Enter") {
|
||||
const newHeight = Math.max(10, parseInt((e.target as HTMLInputElement).value) || 10);
|
||||
setLocalSize((prev) => ({ ...prev, height: newHeight }));
|
||||
handleUpdate("size.height", newHeight);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const dataApi = {
|
|||
*/
|
||||
createRecord: async (tableName: string, data: Record<string, any>): Promise<any> => {
|
||||
const response = await apiClient.post(`/data/${tableName}`, data);
|
||||
return response.data?.data || response.data;
|
||||
return response.data; // success, data, message 포함된 전체 응답 반환
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -94,15 +94,23 @@ export const dataApi = {
|
|||
*/
|
||||
updateRecord: async (tableName: string, id: string | number, data: Record<string, any>): Promise<any> => {
|
||||
const response = await apiClient.put(`/data/${tableName}/${id}`, data);
|
||||
return response.data?.data || response.data;
|
||||
return response.data; // success, data, message 포함된 전체 응답 반환
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 삭제
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
* @param id 레코드 ID 또는 복합키 객체
|
||||
*/
|
||||
deleteRecord: async (tableName: string, id: string | number): Promise<void> => {
|
||||
await apiClient.delete(`/data/${tableName}/${id}`);
|
||||
deleteRecord: async (tableName: string, id: string | number | Record<string, any>): Promise<any> => {
|
||||
// 복합키 객체인 경우 POST로 전달
|
||||
if (typeof id === 'object' && !Array.isArray(id)) {
|
||||
const response = await apiClient.post(`/data/${tableName}/delete`, id);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 단일 ID인 경우 기존 방식
|
||||
const response = await apiClient.delete(`/data/${tableName}/${id}`);
|
||||
return response.data; // success, message 포함된 전체 응답 반환
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// daterange 타입 전용 UI
|
||||
if (webType === "daterange") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
||||
|
|
@ -298,7 +298,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
@ -325,7 +325,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
@ -341,7 +341,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// year 타입 전용 UI (number input with YYYY format)
|
||||
if (webType === "year") {
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
||||
|
|
@ -367,7 +367,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
@ -380,7 +380,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
|
|
@ -401,7 +401,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||
className={cn(
|
||||
"box-border h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
|
|
|
|||
|
|
@ -771,7 +771,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
console.log("🖼️ 대표 이미지 로드 시작:", file.realFileName);
|
||||
// objid가 없거나 유효하지 않으면 로드 중단
|
||||
if (!file.objid || file.objid === "0" || file.objid === "") {
|
||||
console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file);
|
||||
setRepresentativeImageUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🖼️ 대표 이미지 로드 시작:", {
|
||||
objid: file.objid,
|
||||
fileName: file.realFileName,
|
||||
});
|
||||
|
||||
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
|
||||
const response = await apiClient.get(`/files/download/${file.objid}`, {
|
||||
|
|
@ -792,8 +802,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
|
||||
setRepresentativeImageUrl(url);
|
||||
console.log("✅ 대표 이미지 로드 성공:", url);
|
||||
} catch (error) {
|
||||
console.error("❌ 대표 이미지 로드 실패:", error);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 대표 이미지 로드 실패:", {
|
||||
file: file.realFileName,
|
||||
objid: file.objid,
|
||||
error: error?.response?.status || error?.message,
|
||||
});
|
||||
setRepresentativeImageUrl(null);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,8 @@ import { Slider } from "@/components/ui/slider";
|
|||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
|
|
@ -74,6 +75,61 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenTableName]);
|
||||
|
||||
// 좌측 패널 테이블 컬럼 로드 완료 시 PK 자동 추가
|
||||
useEffect(() => {
|
||||
const leftTableName = config.leftPanel?.tableName || screenTableName;
|
||||
if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showAdd) {
|
||||
const currentAddModalColumns = config.leftPanel?.addModalColumns || [];
|
||||
const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns);
|
||||
|
||||
// PK가 추가되었으면 업데이트
|
||||
if (updatedColumns.length !== currentAddModalColumns.length) {
|
||||
console.log(`🔄 좌측 패널: PK 컬럼 자동 추가 (${leftTableName})`);
|
||||
updateLeftPanel({ addModalColumns: updatedColumns });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showAdd]);
|
||||
|
||||
// 좌측 패널 하위 항목 추가 모달 PK 자동 추가
|
||||
useEffect(() => {
|
||||
const leftTableName = config.leftPanel?.tableName || screenTableName;
|
||||
if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showItemAddButton) {
|
||||
const currentAddModalColumns = config.leftPanel?.itemAddConfig?.addModalColumns || [];
|
||||
const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns);
|
||||
|
||||
// PK가 추가되었으면 업데이트
|
||||
if (updatedColumns.length !== currentAddModalColumns.length) {
|
||||
console.log(`🔄 좌측 패널 하위 항목 추가: PK 컬럼 자동 추가 (${leftTableName})`);
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
addModalColumns: updatedColumns,
|
||||
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
|
||||
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showItemAddButton]);
|
||||
|
||||
// 우측 패널 테이블 컬럼 로드 완료 시 PK 자동 추가
|
||||
useEffect(() => {
|
||||
const rightTableName = config.rightPanel?.tableName;
|
||||
if (rightTableName && loadedTableColumns[rightTableName] && config.rightPanel?.showAdd) {
|
||||
const currentAddModalColumns = config.rightPanel?.addModalColumns || [];
|
||||
const updatedColumns = ensurePrimaryKeysInAddModal(rightTableName, currentAddModalColumns);
|
||||
|
||||
// PK가 추가되었으면 업데이트
|
||||
if (updatedColumns.length !== currentAddModalColumns.length) {
|
||||
console.log(`🔄 우측 패널: PK 컬럼 자동 추가 (${rightTableName})`);
|
||||
updateRightPanel({ addModalColumns: updatedColumns });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.rightPanel?.tableName, loadedTableColumns, config.rightPanel?.showAdd]);
|
||||
|
||||
// 테이블 컬럼 로드 함수
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (loadedTableColumns[tableName] || loadingColumns[tableName]) {
|
||||
|
|
@ -98,6 +154,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||
columnDefault: col.columnDefault || col.column_default,
|
||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||
isPrimaryKey: col.isPrimaryKey || false, // PK 여부 추가
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
codeValue: col.codeValue || col.code_value,
|
||||
}));
|
||||
|
|
@ -139,6 +196,44 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// PK 컬럼을 추가 모달에 자동으로 포함시키는 함수
|
||||
const ensurePrimaryKeysInAddModal = (
|
||||
tableName: string,
|
||||
existingColumns: Array<{ name: string; label: string; required?: boolean }> = []
|
||||
) => {
|
||||
const tableColumns = loadedTableColumns[tableName];
|
||||
if (!tableColumns) {
|
||||
console.warn(`⚠️ 테이블 ${tableName}의 컬럼 정보가 로드되지 않음`);
|
||||
return existingColumns;
|
||||
}
|
||||
|
||||
// PK 컬럼 찾기
|
||||
const pkColumns = tableColumns.filter((col) => col.isPrimaryKey);
|
||||
console.log(`🔑 테이블 ${tableName}의 PK 컬럼:`, pkColumns.map(c => c.columnName));
|
||||
|
||||
// 자동으로 처리되는 컬럼 (백엔드에서 자동 추가)
|
||||
const autoHandledColumns = ['company_code', 'company_name'];
|
||||
|
||||
// 기존 컬럼 이름 목록
|
||||
const existingColumnNames = existingColumns.map((col) => col.name);
|
||||
|
||||
// PK 컬럼을 맨 앞에 추가 (이미 있거나 자동 처리되는 컬럼은 제외)
|
||||
const pkColumnsToAdd = pkColumns
|
||||
.filter((col) => !existingColumnNames.includes(col.columnName))
|
||||
.filter((col) => !autoHandledColumns.includes(col.columnName)) // 자동 처리 컬럼 제외
|
||||
.map((col) => ({
|
||||
name: col.columnName,
|
||||
label: col.columnLabel || col.columnName,
|
||||
required: true, // PK는 항상 필수
|
||||
}));
|
||||
|
||||
if (pkColumnsToAdd.length > 0) {
|
||||
console.log(`✅ PK 컬럼 ${pkColumnsToAdd.length}개 자동 추가:`, pkColumnsToAdd.map(c => c.name));
|
||||
}
|
||||
|
||||
return [...pkColumnsToAdd, ...existingColumns];
|
||||
};
|
||||
|
||||
const updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
|
|
@ -190,7 +285,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
console.log(" - availableRightTables:", availableRightTables.length, "개");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
||||
|
|
@ -230,9 +325,14 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 좌측 패널 설정 (마스터) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
{/* 좌측 패널 설정 (Accordion) */}
|
||||
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
|
||||
<AccordionItem value="left-panel" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
좌측 패널 설정 (마스터)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-visible">
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
|
|
@ -268,11 +368,464 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>각 항목에 + 버튼</Label>
|
||||
<Switch
|
||||
checked={config.leftPanel?.showItemAddButton ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showItemAddButton: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 항목별 + 버튼 설정 (하위 항목 추가) */}
|
||||
{config.leftPanel?.showItemAddButton && (
|
||||
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<Label className="text-sm font-semibold">하위 항목 추가 설정</Label>
|
||||
<p className="text-xs text-gray-600">
|
||||
+ 버튼 클릭 시 선택된 항목의 하위 항목을 추가합니다 (예: 부서 → 하위 부서)
|
||||
</p>
|
||||
|
||||
{/* 현재 항목의 값을 가져올 컬럼 (sourceColumn) */}
|
||||
<div>
|
||||
<Label className="text-xs">현재 항목 ID 컬럼</Label>
|
||||
<p className="mb-2 text-[10px] text-gray-500">
|
||||
선택된 항목의 어떤 컬럼 값을 사용할지 (예: dept_code)
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.leftPanel?.itemAddConfig?.sourceColumn || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{leftTableColumns
|
||||
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
|
||||
.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
sourceColumn: value,
|
||||
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.leftPanel?.itemAddConfig?.sourceColumn === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{column.columnLabel || column.columnName}
|
||||
<span className="ml-2 text-[10px] text-gray-500">
|
||||
({column.columnName})
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 상위 항목 ID를 저장할 컬럼 (parentColumn) */}
|
||||
<div>
|
||||
<Label className="text-xs">상위 항목 저장 컬럼</Label>
|
||||
<p className="mb-2 text-[10px] text-gray-500">
|
||||
하위 항목에서 상위 항목 ID를 저장할 컬럼 (예: parent_dept_code)
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.leftPanel?.itemAddConfig?.parentColumn || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{leftTableColumns
|
||||
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
|
||||
.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
parentColumn: value,
|
||||
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.leftPanel?.itemAddConfig?.parentColumn === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{column.columnLabel || column.columnName}
|
||||
<span className="ml-2 text-[10px] text-gray-500">
|
||||
({column.columnName})
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 하위 항목 추가 모달 컬럼 설정 */}
|
||||
<div className="space-y-2 rounded border border-blue-300 bg-white p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">추가 모달 입력 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const currentColumns = config.leftPanel?.itemAddConfig?.addModalColumns || [];
|
||||
const newColumns = [
|
||||
...currentColumns,
|
||||
{ name: "", label: "", required: false },
|
||||
];
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
addModalColumns: newColumns,
|
||||
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
|
||||
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="h-6 text-[10px]"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
하위 항목 추가 시 입력받을 필드를 선택하세요
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.leftPanel?.itemAddConfig?.addModalColumns || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-2 text-center">
|
||||
<p className="text-[10px] text-gray-500">설정된 컬럼이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
(config.leftPanel?.itemAddConfig?.addModalColumns || []).map((col, index) => {
|
||||
const column = leftTableColumns.find(c => c.columnName === col.name);
|
||||
const isPK = column?.isPrimaryKey || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
isPK ? "border-yellow-300 bg-yellow-50" : "bg-white"
|
||||
)}
|
||||
>
|
||||
{isPK && (
|
||||
<span className="rounded bg-yellow-200 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-700">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={isPK}
|
||||
className="h-7 w-full justify-between text-[10px]"
|
||||
>
|
||||
{col.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{leftTableColumns
|
||||
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
|
||||
.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
const newColumns = [...(config.leftPanel?.itemAddConfig?.addModalColumns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
name: value,
|
||||
label: column.columnLabel || value,
|
||||
};
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
addModalColumns: newColumns,
|
||||
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
|
||||
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
col.name === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{column.columnLabel || column.columnName}
|
||||
<span className="ml-2 text-[10px] text-gray-500">
|
||||
({column.columnName})
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="flex cursor-pointer items-center gap-1 text-[10px] text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.required ?? false}
|
||||
disabled={isPK}
|
||||
onChange={(e) => {
|
||||
const newColumns = [...(config.leftPanel?.itemAddConfig?.addModalColumns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
required: e.target.checked,
|
||||
};
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
addModalColumns: newColumns,
|
||||
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
|
||||
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
필수
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isPK}
|
||||
onClick={() => {
|
||||
const newColumns = (config.leftPanel?.itemAddConfig?.addModalColumns || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
updateLeftPanel({
|
||||
itemAddConfig: {
|
||||
...config.leftPanel?.itemAddConfig,
|
||||
addModalColumns: newColumns,
|
||||
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
|
||||
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
title={isPK ? "PK 컬럼은 삭제할 수 없습니다" : ""}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 좌측 패널 추가 모달 컬럼 설정 */}
|
||||
{config.leftPanel?.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">추가 모달 입력 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const currentColumns = config.leftPanel?.addModalColumns || [];
|
||||
const newColumns = [
|
||||
...currentColumns,
|
||||
{ name: "", label: "", required: false },
|
||||
];
|
||||
updateLeftPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
추가 버튼 클릭 시 모달에 표시될 입력 필드를 선택하세요
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.leftPanel?.addModalColumns || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
(config.leftPanel?.addModalColumns || []).map((col, index) => {
|
||||
// 현재 컬럼이 PK인지 확인
|
||||
const column = leftTableColumns.find(c => c.columnName === col.name);
|
||||
const isPK = column?.isPrimaryKey || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
isPK ? "bg-yellow-50 border-yellow-300" : "bg-white"
|
||||
)}
|
||||
>
|
||||
{isPK && (
|
||||
<span className="text-[10px] font-semibold text-yellow-700 px-1.5 py-0.5 bg-yellow-200 rounded">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={isPK}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{col.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{leftTableColumns
|
||||
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
|
||||
.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
const newColumns = [...(config.leftPanel?.addModalColumns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
name: value,
|
||||
label: column.columnLabel || value,
|
||||
};
|
||||
updateLeftPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
col.name === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{column.columnLabel || column.columnName}
|
||||
<span className="ml-2 text-[10px] text-gray-500">
|
||||
({column.columnName})
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.required ?? false}
|
||||
disabled={isPK}
|
||||
onChange={(e) => {
|
||||
const newColumns = [...(config.leftPanel?.addModalColumns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
required: e.target.checked,
|
||||
};
|
||||
updateLeftPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
필수
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isPK}
|
||||
onClick={() => {
|
||||
const newColumns = (config.leftPanel?.addModalColumns || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
updateLeftPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
title={isPK ? "PK 컬럼은 삭제할 수 없습니다" : ""}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 우측 패널 설정 (Accordion) */}
|
||||
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
|
||||
<AccordionItem value="right-panel" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-visible">
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
|
|
@ -467,11 +1020,370 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
||||
{/* 우측 패널 표시 컬럼 설정 */}
|
||||
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">표시할 컬럼 선택</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const currentColumns = config.rightPanel?.columns || [];
|
||||
const newColumns = [
|
||||
...currentColumns,
|
||||
{ name: "", label: "", width: 100 },
|
||||
];
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
disabled={!config.rightPanel?.tableName}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
우측 패널에 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다.
|
||||
</p>
|
||||
|
||||
{/* 선택된 컬럼 목록 */}
|
||||
<div className="space-y-2">
|
||||
{(config.rightPanel?.columns || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
||||
<p className="mt-1 text-[10px] text-gray-400">
|
||||
컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(config.rightPanel?.columns || []).map((col, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 rounded-md border bg-white p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{col.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{rightTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
name: value,
|
||||
label: column.columnLabel || value,
|
||||
};
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
col.name === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{column.columnLabel || column.columnName}
|
||||
<span className="ml-2 text-[10px] text-gray-500">
|
||||
({column.columnName})
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newColumns = (config.rightPanel?.columns || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 추가 모달 컬럼 설정 */}
|
||||
{config.rightPanel?.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">추가 모달 입력 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const currentColumns = config.rightPanel?.addModalColumns || [];
|
||||
const newColumns = [
|
||||
...currentColumns,
|
||||
{ name: "", label: "", required: false },
|
||||
];
|
||||
updateRightPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
disabled={!config.rightPanel?.tableName}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
추가 버튼 클릭 시 모달에 표시될 입력 필드를 선택하세요
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.rightPanel?.addModalColumns || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
(config.rightPanel?.addModalColumns || []).map((col, index) => {
|
||||
// 현재 컬럼이 PK인지 확인
|
||||
const column = rightTableColumns.find(c => c.columnName === col.name);
|
||||
const isPK = column?.isPrimaryKey || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
isPK ? "bg-yellow-50 border-yellow-300" : "bg-white"
|
||||
)}
|
||||
>
|
||||
{isPK && (
|
||||
<span className="text-[10px] font-semibold text-yellow-700 px-1.5 py-0.5 bg-yellow-200 rounded">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={isPK}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{col.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{rightTableColumns
|
||||
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
|
||||
.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
const newColumns = [...(config.rightPanel?.addModalColumns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
name: value,
|
||||
label: column.columnLabel || value,
|
||||
};
|
||||
updateRightPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
col.name === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{column.columnLabel || column.columnName}
|
||||
<span className="ml-2 text-[10px] text-gray-500">
|
||||
({column.columnName})
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.required ?? false}
|
||||
disabled={isPK}
|
||||
onChange={(e) => {
|
||||
const newColumns = [...(config.rightPanel?.addModalColumns || [])];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
required: e.target.checked,
|
||||
};
|
||||
updateRightPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
필수
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isPK}
|
||||
onClick={() => {
|
||||
const newColumns = (config.rightPanel?.addModalColumns || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
updateRightPanel({ addModalColumns: newColumns });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
title={isPK ? "PK 컬럼은 삭제할 수 없습니다" : ""}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 중계 테이블 설정 */}
|
||||
<div className="space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-3 mt-3">
|
||||
<Label className="text-sm font-semibold">중계 테이블 설정 (N:M 관계)</Label>
|
||||
<p className="text-xs text-gray-600">
|
||||
중계 테이블을 사용하여 다대다 관계를 구현합니다
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-700">실제 저장할 테이블</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.addConfig?.targetTable || ""}
|
||||
onChange={(e) => {
|
||||
const addConfig = config.rightPanel?.addConfig || {};
|
||||
updateRightPanel({
|
||||
addConfig: {
|
||||
...addConfig,
|
||||
targetTable: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: user_dept"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
데이터가 실제로 저장될 중계 테이블명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-700">좌측 패널 컬럼</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.addConfig?.leftPanelColumn || ""}
|
||||
onChange={(e) => {
|
||||
const addConfig = config.rightPanel?.addConfig || {};
|
||||
updateRightPanel({
|
||||
addConfig: {
|
||||
...addConfig,
|
||||
leftPanelColumn: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: dept_code"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
좌측 패널에서 선택한 항목의 어떤 컬럼값을 가져올지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-700">중계 테이블 대상 컬럼</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.addConfig?.targetColumn || ""}
|
||||
onChange={(e) => {
|
||||
const addConfig = config.rightPanel?.addConfig || {};
|
||||
updateRightPanel({
|
||||
addConfig: {
|
||||
...addConfig,
|
||||
targetColumn: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: dept_code"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
중계 테이블의 어떤 컬럼에 좌측값을 저장할지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-700">자동 채움 컬럼 (JSON)</Label>
|
||||
<textarea
|
||||
value={JSON.stringify(config.rightPanel?.addConfig?.autoFillColumns || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
const addConfig = config.rightPanel?.addConfig || {};
|
||||
updateRightPanel({
|
||||
addConfig: {
|
||||
...addConfig,
|
||||
autoFillColumns: parsed,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// JSON 파싱 오류는 무시 (입력 중)
|
||||
}
|
||||
}}
|
||||
placeholder='{ "is_primary": false }'
|
||||
className="mt-1 h-20 w-full rounded-md border border-input bg-white px-3 py-2 text-xs font-mono"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
자동으로 채워질 컬럼과 기본값 (예: is_primary: false)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 레이아웃 설정 (Accordion) */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="layout" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
||||
레이아웃 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-visible">
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
|
|
@ -499,7 +1411,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,11 +10,34 @@ export interface SplitPanelLayoutConfig {
|
|||
dataSource?: string; // API 엔드포인트
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
showEdit?: boolean; // 수정 버튼
|
||||
showDelete?: boolean; // 삭제 버튼
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
}>;
|
||||
// 추가 모달에서 입력받을 컬럼 설정
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
// 각 항목에 + 버튼 표시 (하위 항목 추가)
|
||||
showItemAddButton?: boolean;
|
||||
// + 버튼 클릭 시 하위 항목 추가를 위한 설정
|
||||
itemAddConfig?: {
|
||||
// 하위 항목 추가 모달에서 입력받을 컬럼
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
// 상위 항목의 ID를 저장할 컬럼 (예: parent_dept_code)
|
||||
parentColumn: string;
|
||||
// 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code)
|
||||
sourceColumn: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 우측 패널 설정
|
||||
|
|
@ -24,18 +47,35 @@ export interface SplitPanelLayoutConfig {
|
|||
dataSource?: string;
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
showEdit?: boolean; // 수정 버튼
|
||||
showDelete?: boolean; // 삭제 버튼
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
}>;
|
||||
// 추가 모달에서 입력받을 컬럼 설정
|
||||
addModalColumns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
|
||||
// 좌측 선택 항목과의 관계 설정
|
||||
relation?: {
|
||||
type: "join" | "detail"; // 관계 타입
|
||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼
|
||||
rightColumn?: string; // 우측 테이블의 연결 컬럼 (join용)
|
||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||
};
|
||||
|
||||
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
|
||||
addConfig?: {
|
||||
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
|
||||
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
|
||||
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
|
||||
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
|
||||
};
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
|
|
|
|||
|
|
@ -1,77 +1,205 @@
|
|||
import { Position, Size } from "@/types/screen";
|
||||
import { GridSettings } from "@/types/screen-management";
|
||||
|
||||
// 🎯 10px 고정 격자 시스템
|
||||
const GRID_SIZE = 10; // 고정값
|
||||
|
||||
export interface GridInfo {
|
||||
gridSize: number; // 항상 10px
|
||||
columnWidth: number;
|
||||
totalWidth: number;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 정보 계산 (단순화)
|
||||
* 격자 정보 계산
|
||||
*/
|
||||
export function calculateGridInfo(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
_gridSettings?: GridSettings, // 호환성 유지용 (사용 안 함)
|
||||
gridSettings: GridSettings,
|
||||
): GridInfo {
|
||||
const { gap, padding } = gridSettings;
|
||||
let { columns } = gridSettings;
|
||||
|
||||
// 🔥 최소 컬럼 너비를 보장하기 위한 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30; // 최소 컬럼 너비 30px
|
||||
const availableWidth = containerWidth - padding * 2;
|
||||
const maxPossibleColumns = Math.floor((availableWidth + gap) / (MIN_COLUMN_WIDTH + gap));
|
||||
|
||||
// 설정된 컬럼 수가 너무 많으면 자동으로 제한
|
||||
if (columns > maxPossibleColumns) {
|
||||
console.warn(
|
||||
`⚠️ 격자 컬럼 수가 너무 많습니다. ${columns}개 → ${maxPossibleColumns}개로 자동 조정됨 (최소 컬럼 너비: ${MIN_COLUMN_WIDTH}px)`,
|
||||
);
|
||||
columns = Math.max(1, maxPossibleColumns);
|
||||
}
|
||||
|
||||
// 격자 간격을 고려한 컬럼 너비 계산
|
||||
const totalGaps = (columns - 1) * gap;
|
||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||
|
||||
return {
|
||||
gridSize: GRID_SIZE,
|
||||
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
|
||||
totalWidth: containerWidth,
|
||||
totalHeight: containerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치를 10px 격자에 맞춤
|
||||
* 위치를 격자에 맞춤
|
||||
*/
|
||||
export function snapToGrid(position: Position, _gridInfo: GridInfo, gridSettings: GridSettings): Position {
|
||||
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
|
||||
if (!gridSettings.snapToGrid) {
|
||||
return position;
|
||||
}
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap, padding } = gridSettings;
|
||||
|
||||
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = 10; // 행 높이 10px 단위로 고정
|
||||
|
||||
// 패딩을 제외한 상대 위치
|
||||
const relativeX = position.x - padding;
|
||||
const relativeY = position.y - padding;
|
||||
|
||||
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
|
||||
const gridX = Math.round(relativeX / cellWidth);
|
||||
const gridY = Math.round(relativeY / cellHeight);
|
||||
|
||||
// 실제 픽셀 위치로 변환
|
||||
const snappedX = Math.max(padding, padding + gridX * cellWidth);
|
||||
const snappedY = Math.max(padding, padding + gridY * cellHeight);
|
||||
|
||||
return {
|
||||
x: Math.round(position.x / GRID_SIZE) * GRID_SIZE,
|
||||
y: Math.round(position.y / GRID_SIZE) * GRID_SIZE,
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
z: position.z,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 크기를 10px 격자에 맞춤
|
||||
* 크기를 격자에 맞춤
|
||||
*/
|
||||
export function snapSizeToGrid(size: Size, _gridInfo: GridInfo, gridSettings: GridSettings): Size {
|
||||
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
|
||||
if (!gridSettings.snapToGrid) {
|
||||
return size;
|
||||
}
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
// 격자 단위로 너비 계산
|
||||
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
|
||||
let gridColumns = 1;
|
||||
|
||||
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
|
||||
for (let cols = 1; cols <= gridSettings.columns; cols++) {
|
||||
const targetWidth = cols * columnWidth + (cols - 1) * gap;
|
||||
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
|
||||
gridColumns = cols;
|
||||
break;
|
||||
}
|
||||
gridColumns = cols;
|
||||
}
|
||||
|
||||
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||
|
||||
// 높이는 10px 단위로 스냅
|
||||
const rowHeight = 10;
|
||||
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
|
||||
|
||||
console.log(
|
||||
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(GRID_SIZE, Math.round(size.width / GRID_SIZE) * GRID_SIZE),
|
||||
height: Math.max(GRID_SIZE, Math.round(size.height / GRID_SIZE) * GRID_SIZE),
|
||||
width: Math.max(columnWidth, snappedWidth),
|
||||
height: snappedHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 가이드라인 생성 (10px 간격)
|
||||
* 격자 컬럼 수로 너비 계산
|
||||
*/
|
||||
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
return columns * columnWidth + (columns - 1) * gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* gridColumns 속성을 기반으로 컴포넌트 크기 업데이트
|
||||
*/
|
||||
export function updateSizeFromGridColumns(
|
||||
component: { gridColumns?: number; size: Size },
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (!component.gridColumns || component.gridColumns < 1) {
|
||||
return component.size;
|
||||
}
|
||||
|
||||
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
|
||||
|
||||
return {
|
||||
width: newWidth,
|
||||
height: component.size.height, // 높이는 유지
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정
|
||||
*/
|
||||
export function adjustGridColumnsFromSize(
|
||||
component: { size: Size },
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): number {
|
||||
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
|
||||
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
|
||||
}
|
||||
|
||||
/**
|
||||
* 너비에서 격자 컬럼 수 계산
|
||||
*/
|
||||
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 가이드라인 생성
|
||||
*/
|
||||
export function generateGridLines(
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
_gridSettings?: GridSettings,
|
||||
gridSettings: GridSettings,
|
||||
): {
|
||||
verticalLines: number[];
|
||||
horizontalLines: number[];
|
||||
} {
|
||||
const { columns, gap, padding } = gridSettings;
|
||||
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
|
||||
const { columnWidth } = gridInfo;
|
||||
|
||||
// 격자 셀 크기 (스냅 로직과 동일하게)
|
||||
const cellWidth = columnWidth + gap;
|
||||
const cellHeight = 10; // 행 높이 10px 단위로 고정
|
||||
|
||||
// 세로 격자선
|
||||
const verticalLines: number[] = [];
|
||||
for (let x = 0; x <= containerWidth; x += GRID_SIZE) {
|
||||
verticalLines.push(x);
|
||||
for (let i = 0; i <= columns; i++) {
|
||||
const x = padding + i * cellWidth;
|
||||
if (x <= containerWidth) {
|
||||
verticalLines.push(x);
|
||||
}
|
||||
}
|
||||
|
||||
// 가로 격자선
|
||||
const horizontalLines: number[] = [];
|
||||
for (let y = 0; y <= containerHeight; y += GRID_SIZE) {
|
||||
for (let y = padding; y < containerHeight; y += cellHeight) {
|
||||
horizontalLines.push(y);
|
||||
}
|
||||
|
||||
|
|
@ -114,21 +242,46 @@ export function alignGroupChildrenToGrid(
|
|||
): any[] {
|
||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||
|
||||
return children.map((child) => {
|
||||
const padding = 16;
|
||||
|
||||
// 10px 단위로 스냅
|
||||
const snappedX = Math.max(padding, Math.round((child.position.x - padding) / GRID_SIZE) * GRID_SIZE + padding);
|
||||
const snappedY = Math.max(padding, Math.round((child.position.y - padding) / GRID_SIZE) * GRID_SIZE + padding);
|
||||
|
||||
const snappedWidth = Math.max(GRID_SIZE, Math.round(child.size.width / GRID_SIZE) * GRID_SIZE);
|
||||
const snappedHeight = Math.max(GRID_SIZE, Math.round(child.size.height / GRID_SIZE) * GRID_SIZE);
|
||||
console.log("🔧 alignGroupChildrenToGrid 시작:", {
|
||||
childrenCount: children.length,
|
||||
groupPosition,
|
||||
gridInfo,
|
||||
gridSettings,
|
||||
});
|
||||
|
||||
return {
|
||||
return children.map((child, index) => {
|
||||
console.log(`📐 자식 ${index + 1} 처리 중:`, {
|
||||
childId: child.id,
|
||||
originalPosition: child.position,
|
||||
originalSize: child.size,
|
||||
});
|
||||
|
||||
const { columnWidth } = gridInfo;
|
||||
const { gap } = gridSettings;
|
||||
|
||||
// 그룹 내부 패딩 고려한 격자 정렬
|
||||
const padding = 16;
|
||||
const effectiveX = child.position.x - padding;
|
||||
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
|
||||
const snappedX = padding + columnIndex * (columnWidth + gap);
|
||||
|
||||
// Y 좌표는 10px 단위로 스냅
|
||||
const rowHeight = 10;
|
||||
const effectiveY = child.position.y - padding;
|
||||
const rowIndex = Math.round(effectiveY / rowHeight);
|
||||
const snappedY = padding + rowIndex * rowHeight;
|
||||
|
||||
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
|
||||
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
|
||||
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
|
||||
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
|
||||
const snappedHeight = Math.max(10, Math.round(child.size.height / rowHeight) * rowHeight);
|
||||
|
||||
const snappedChild = {
|
||||
...child,
|
||||
position: {
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
|
||||
y: Math.max(padding, snappedY),
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
size: {
|
||||
|
|
@ -136,6 +289,26 @@ export function alignGroupChildrenToGrid(
|
|||
height: snappedHeight,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
|
||||
childId: child.id,
|
||||
calculation: {
|
||||
effectiveX,
|
||||
effectiveY,
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
widthInColumns,
|
||||
originalX: child.position.x,
|
||||
snappedX: snappedChild.position.x,
|
||||
padding,
|
||||
},
|
||||
snappedPosition: snappedChild.position,
|
||||
snappedSize: snappedChild.size,
|
||||
deltaX: snappedChild.position.x - child.position.x,
|
||||
deltaY: snappedChild.position.y - child.position.y,
|
||||
});
|
||||
|
||||
return snappedChild;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -144,13 +317,19 @@ export function alignGroupChildrenToGrid(
|
|||
*/
|
||||
export function calculateOptimalGroupSize(
|
||||
children: Array<{ position: Position; size: Size }>,
|
||||
_gridInfo?: GridInfo,
|
||||
_gridSettings?: GridSettings,
|
||||
gridInfo: GridInfo,
|
||||
gridSettings: GridSettings,
|
||||
): Size {
|
||||
if (children.length === 0) {
|
||||
return { width: GRID_SIZE * 20, height: GRID_SIZE * 10 };
|
||||
return { width: gridInfo.columnWidth * 2, height: 10 * 4 };
|
||||
}
|
||||
|
||||
console.log("📏 calculateOptimalGroupSize 시작:", {
|
||||
childrenCount: children.length,
|
||||
children: children.map((c) => ({ pos: c.position, size: c.size })),
|
||||
});
|
||||
|
||||
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
|
||||
const bounds = children.reduce(
|
||||
(acc, child) => ({
|
||||
minX: Math.min(acc.minX, child.position.x),
|
||||
|
|
@ -161,38 +340,61 @@ export function calculateOptimalGroupSize(
|
|||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
|
||||
console.log("📐 경계 계산:", bounds);
|
||||
|
||||
const contentWidth = bounds.maxX - bounds.minX;
|
||||
const contentHeight = bounds.maxY - bounds.minY;
|
||||
const padding = 16;
|
||||
|
||||
return {
|
||||
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
|
||||
const padding = 16; // 그룹 내부 여백
|
||||
const groupSize = {
|
||||
width: contentWidth + padding * 2,
|
||||
height: contentHeight + padding * 2,
|
||||
};
|
||||
|
||||
console.log("✅ 자연스러운 그룹 크기:", {
|
||||
contentSize: { width: contentWidth, height: contentHeight },
|
||||
withPadding: groupSize,
|
||||
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
|
||||
});
|
||||
|
||||
return groupSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 내 상대 좌표를 격자 기준으로 정규화
|
||||
*/
|
||||
export function normalizeGroupChildPositions(children: any[], _gridSettings?: GridSettings): any[] {
|
||||
if (children.length === 0) return children;
|
||||
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
|
||||
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||
|
||||
console.log("🔄 normalizeGroupChildPositions 시작:", {
|
||||
childrenCount: children.length,
|
||||
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
|
||||
});
|
||||
|
||||
// 모든 자식의 최소 위치 찾기
|
||||
const minX = Math.min(...children.map((child) => child.position.x));
|
||||
const minY = Math.min(...children.map((child) => child.position.y));
|
||||
const padding = 16;
|
||||
|
||||
return children.map((child) => ({
|
||||
console.log("📍 최소 위치:", { minX, minY });
|
||||
|
||||
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
|
||||
const padding = 16;
|
||||
const startX = padding;
|
||||
const startY = padding;
|
||||
|
||||
const normalizedChildren = children.map((child) => ({
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - minX + padding,
|
||||
y: child.position.y - minY + padding,
|
||||
x: child.position.x - minX + startX,
|
||||
y: child.position.y - minY + startY,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 🗑️ 제거된 함수들 (더 이상 필요 없음)
|
||||
// - calculateWidthFromColumns
|
||||
// - updateSizeFromGridColumns
|
||||
// - adjustGridColumnsFromSize
|
||||
// - calculateColumnsFromWidth
|
||||
console.log("✅ 정규화 완료:", {
|
||||
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
|
||||
});
|
||||
|
||||
return normalizedChildren;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -561,22 +561,21 @@ export interface LayoutData {
|
|||
}
|
||||
|
||||
/**
|
||||
* 격자 설정 (10px 고정 격자)
|
||||
* 격자 설정
|
||||
*/
|
||||
export interface GridSettings {
|
||||
snapToGrid: boolean; // 격자 스냅 ON/OFF
|
||||
showGrid?: boolean; // 격자 표시 여부
|
||||
gridColor?: string; // 격자 선 색상
|
||||
gridOpacity?: number; // 격자 선 투명도
|
||||
|
||||
// 🗑️ 제거된 속성들 (10px 고정으로 더 이상 필요 없음)
|
||||
// - columns: 자동 계산 (해상도 ÷ 10px)
|
||||
// - gap: 10px 고정
|
||||
// - padding: 0px 고정
|
||||
// - size: 10px 고정
|
||||
// - enabled: showGrid로 대체
|
||||
// - color: gridColor로 대체
|
||||
// - opacity: gridOpacity로 대체
|
||||
enabled: boolean;
|
||||
size: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
snapToGrid: boolean;
|
||||
// gridUtils에서 필요한 속성들 추가
|
||||
columns: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
showGrid?: boolean;
|
||||
gridColor?: string;
|
||||
gridOpacity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue