Compare commits

...

19 Commits

Author SHA1 Message Date
kjs 7ab3781372 feat: 드래그 드롭 컬럼 라벨 숨김 및 placeholder 표시
- 테이블 탭에서 드래그 드롭으로 배치되는 컬럼의 라벨 자동 숨김 (labelDisplay: false)
- placeholder에 컬럼 라벨명 자동 표시
- 폼 컨테이너 및 캔버스 직접 드롭 모두 적용
- 더 깔끔한 UI 제공
2025-11-10 14:36:53 +09:00
kjs 3f32996014 fix: 날짜 입력 필드 높이 반응형 개선
- 드래그 드롭으로 배치되는 인풋 필드 기본 높이 30px로 변경
- 날짜 입력 컴포넌트의 모든 외부 div에 h-full 추가
- 모든 input 요소에 min-h-full 추가하여 부모 높이를 제대로 따르도록 수정
- daterange, year 타입도 동일하게 적용
2025-11-10 14:33:15 +09:00
kjs a868c5c413 feat: 테이블 탭에서 시스템 컬럼 5개 숨김 처리
- id, created_date, updated_date, writer, company_code 컬럼 필터링
- 대소문자 구분 없이 시스템 컬럼 제외
- 화면 편집기 테이블 탭에서 비즈니스 컬럼만 표시
2025-11-10 14:24:16 +09:00
kjs 15f21a1142 revert: e27845a 커밋의 변경사항 되돌림 - 화면 레이아웃 문제 수정 2025-11-10 14:21:29 +09:00
kjs 02644f38ee Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-11-10 12:00:21 +09:00
kjs ce3ba22c54 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-11-10 12:00:08 +09:00
kjs 61dc48e638 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-11-10 11:59:57 +09:00
hyeonsu 4b540dc587 Merge pull request 'feat/screenDesinger' (#196) from feat/screenDesinger into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/196
2025-11-10 11:57:32 +09:00
dohyeons e9f0244210 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/screenDesinger 2025-11-10 11:56:56 +09:00
dohyeons 68c3db5213 feat: 분할 패널 레이아웃 멀티테넌시 및 자동 필터링 기능 추가
- 데이터 조회 API에 회사별 자동 필터링 기능 추가
  - GET /api/data/:tableName에 company_code 필터 자동 적용
  - GET /api/data/join에 우측 테이블 회사별 필터링 추가
  - 최고 관리자(company_code = '*')는 전체 데이터 조회 가능

- 분할 패널 레이아웃 우측 추가 시 조인 컬럼 자동 입력
  - 좌측에서 선택한 항목의 조인 키 값을 우측 추가 모달에 자동 설정
  - 자동 설정된 필드는 읽기 전용으로 표시 (disabled + 안내 문구)
  - 사용자는 나머지 필드만 입력하면 됨

- 데이터 서비스 개선
  - getJoinedData 함수에 companyCode 파라미터 추가
  - checkColumnExists 함수를 public으로 변경하여 재사용성 향상
  - 조인 쿼리에 DISTINCT 추가로 중복 데이터 방지
  - 복합키 테이블의 레코드 삭제 지원

- 레코드 생성 시 멀티테넌시 자동 처리
  - company_code와 company_name 자동 추가
  - 테이블 컬럼 존재 여부 체크 후 자동 설정

- 분할 패널 설정 UI 개선
  - 좌측 패널 표시 컬럼 선택 UI 추가
  - 추가 폼에 표시할 컬럼 선택 기능 추가
  - Primary Key 정보 자동 조회 및 표시
2025-11-10 11:56:39 +09:00
kjs 94846e92ef Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-11-10 09:36:05 +09:00
hyeonsu e10d6a3b94 Merge pull request 'feat/screenDesinger' (#194) from feat/screenDesinger into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/194
2025-11-07 18:21:04 +09:00
dohyeons 68577a09f9 우측 패널 항목 삭제 기능 구현 2025-11-07 18:20:24 +09:00
dohyeons 3009d1eecc 삭제버튼 수정버튼 토글 삭제 2025-11-07 16:09:58 +09:00
dohyeons afea879920 수정/삭제 기능 구현 2025-11-07 16:02:01 +09:00
dohyeons 672aba8404 계층 구조 트리 뷰 2025-11-07 15:21:44 +09:00
dohyeons efaa267d78 분할 패널에서 부서 추가 기능 구현 2025-11-07 14:22:23 +09:00
dohyeons 7835898a09 우측 패널 조절 가능하도록 수정 2025-11-07 12:00:46 +09:00
dohyeons 25740c499d 좌측 패널에 매핑한 컬럼 나오도록 구현 2025-11-07 11:51:44 +09:00
23 changed files with 3602 additions and 461 deletions

View File

@ -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. **철저한 마무리** ✨
- 로그 제거, 테스트, 명확한 설명
---
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**

View File

@ -1,22 +1,5 @@
# Cursor Rules for ERP-node Project # Cursor Rules for ERP-node Project
## 🔥 필수 확인 규칙 (작업 시작 전 & 완료 후)
**AI 에이전트는 모든 작업을 시작하기 전과 완료한 후에 반드시 다음 파일을 확인해야 합니다:**
- [AI-개발자 협업 작업 수칙](.cursor/rules/ai-developer-collaboration-rules.mdc)
**핵심 3원칙:**
1. **확인 우선** 🔍 - 추측하지 말고, 항상 확인하고 작업
2. **한 번에 하나** 🎯 - 여러 문제를 동시에 해결하려 하지 말기
3. **철저한 마무리** ✨ - 로그 제거, 테스트, 명확한 설명
**절대 금지:**
- ❌ 확인 없이 "완료했습니다" 말하기
- ❌ 데이터베이스 컬럼명 추측하기 (반드시 MCP로 확인)
- ❌ 디버깅 로그를 남겨둔 채 작업 종료
---
## 🚨 최우선 보안 규칙: 멀티테넌시 ## 🚨 최우선 보안 규칙: 멀티테넌시
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:** **모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**

1
.gitignore vendored
View File

@ -287,4 +287,3 @@ uploads/
*.hwpx *.hwpx
claude.md claude.md
.cursor/rules/ai-developer-collaboration-rules.mdc

View File

@ -51,21 +51,26 @@ router.get(
} }
} }
// 회사 코드 추출 (멀티테넌시 필터링)
const userCompany = req.user?.companyCode;
console.log(`🔗 조인 데이터 조회:`, { console.log(`🔗 조인 데이터 조회:`, {
leftTable, leftTable,
rightTable, rightTable,
leftColumn, leftColumn,
rightColumn, rightColumn,
leftValue, leftValue,
userCompany,
}); });
// 조인 데이터 조회 // 조인 데이터 조회 (회사 코드 전달)
const result = await dataService.getJoinedData( const result = await dataService.getJoinedData(
leftTable as string, leftTable as string,
rightTable as string, rightTable as string,
leftColumn as string, leftColumn as string,
rightColumn as string, rightColumn as string,
leftValue as string leftValue as string,
userCompany
); );
if (!result.success) { if (!result.success) {
@ -352,8 +357,25 @@ router.post(
console.log(` 레코드 생성: ${tableName}`, data); 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) { if (!result.success) {
return res.status(400).json(result); return res.status(400).json(result);
@ -437,6 +459,58 @@ router.put(
* API * API
* DELETE /api/data/{tableName}/{id} * 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( router.delete(
"/:tableName/:id", "/:tableName/:id",
authenticateToken, authenticateToken,

View File

@ -165,12 +165,13 @@ export class AuthService {
const authNames = authResult.map((row) => row.auth_name).join(","); const authNames = authResult.map((row) => row.auth_name).join(",");
// 3. 회사 정보 조회 (Raw Query 전환) // 3. 회사 정보 조회 (Raw Query 전환)
// Note: 현재 회사 정보는 PersonBean에 직접 사용되지 않지만 향후 확장을 위해 유지
const companyResult = await query<{ company_name: string }>( const companyResult = await query<{ company_name: string }>(
"SELECT company_name FROM company_mng WHERE company_code = $1", "SELECT company_name FROM company_mng WHERE company_code = $1",
[userInfo.company_code || "ILSHIN"] [userInfo.company_code || "ILSHIN"]
); );
const companyName = companyResult.length > 0 ? companyResult[0].company_name : undefined;
// DB에서 조회한 원본 사용자 정보 상세 로그 // DB에서 조회한 원본 사용자 정보 상세 로그
//console.log("🔍 AuthService - DB 원본 사용자 정보:", { //console.log("🔍 AuthService - DB 원본 사용자 정보:", {
// userId: userInfo.user_id, // userId: userInfo.user_id,
@ -205,6 +206,7 @@ export class AuthService {
partnerObjid: userInfo.partner_objid || undefined, partnerObjid: userInfo.partner_objid || undefined,
authName: authNames || undefined, authName: authNames || undefined,
companyCode: companyCode, companyCode: companyCode,
companyName: companyName, // 회사명 추가
photo: userInfo.photo photo: userInfo.photo
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
: undefined, : undefined,

View File

@ -231,6 +231,9 @@ class DataService {
const columns = await this.getTableColumnsSimple(tableName); const columns = await this.getTableColumnsSimple(tableName);
// PK 컬럼 정보 조회
const pkColumns = await this.getPrimaryKeyColumns(tableName);
// 컬럼 라벨 정보 추가 // 컬럼 라벨 정보 추가
const columnsWithLabels = await Promise.all( const columnsWithLabels = await Promise.all(
columns.map(async (column) => { columns.map(async (column) => {
@ -244,6 +247,7 @@ class DataService {
dataType: column.data_type, dataType: column.data_type,
isNullable: column.is_nullable === "YES", isNullable: column.is_nullable === "YES",
defaultValue: column.column_default, 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, tableName: string,
columnName: string columnName: string
): Promise<boolean> { ): Promise<boolean> {
@ -409,7 +433,8 @@ class DataService {
rightTable: string, rightTable: string,
leftColumn: string, leftColumn: string,
rightColumn: string, rightColumn: string,
leftValue?: string | number leftValue?: string | number,
userCompany?: string
): Promise<ServiceResponse<any[]>> { ): Promise<ServiceResponse<any[]>> {
try { try {
// 왼쪽 테이블 접근 검증 // 왼쪽 테이블 접근 검증
@ -425,18 +450,42 @@ class DataService {
} }
let queryText = ` let queryText = `
SELECT r.* SELECT DISTINCT r.*
FROM "${rightTable}" r FROM "${rightTable}" r
INNER JOIN "${leftTable}" l INNER JOIN "${leftTable}" l
ON l."${leftColumn}" = r."${rightColumn}" ON l."${leftColumn}" = r."${rightColumn}"
`; `;
const values: any[] = []; const values: any[] = [];
const whereConditions: string[] = [];
let paramIndex = 1;
// 좌측 값 필터링
if (leftValue !== undefined && leftValue !== null) { if (leftValue !== undefined && leftValue !== null) {
queryText += ` WHERE l."${leftColumn}" = $1`; whereConditions.push(`l."${leftColumn}" = $${paramIndex}`);
values.push(leftValue); 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); const result = await query<any>(queryText, values);
return { return {
@ -512,6 +561,11 @@ class DataService {
return validation.error!; return validation.error!;
} }
// _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo;
const cleanData = { ...data };
delete cleanData._relationInfo;
// Primary Key 컬럼 찾기 // Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>( const pkResult = await query<{ attname: string }>(
`SELECT a.attname `SELECT a.attname
@ -526,8 +580,8 @@ class DataService {
pkColumn = pkResult[0].attname; pkColumn = pkResult[0].attname;
} }
const columns = Object.keys(data); const columns = Object.keys(cleanData);
const values = Object.values(data); const values = Object.values(cleanData);
const setClause = columns const setClause = columns
.map((col, index) => `"${col}" = $${index + 1}`) .map((col, index) => `"${col}" = $${index + 1}`)
.join(", "); .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 { return {
success: true, success: true,
data: result[0], data: result[0],
@ -569,7 +652,7 @@ class DataService {
*/ */
async deleteRecord( async deleteRecord(
tableName: string, tableName: string,
id: string | number id: string | number | Record<string, any>
): Promise<ServiceResponse<void>> { ): Promise<ServiceResponse<void>> {
try { try {
// 테이블 접근 검증 // 테이블 접근 검증
@ -578,28 +661,53 @@ class DataService {
return validation.error!; return validation.error!;
} }
// Primary Key 컬럼 찾기 // Primary Key 컬럼 찾기 (복합키 지원)
const pkResult = await query<{ attname: string }>( const pkResult = await query<{ attname: string }>(
`SELECT a.attname `SELECT a.attname
FROM pg_index i FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) 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] [tableName]
); );
let pkColumn = "id"; let whereClauses: string[] = [];
if (pkResult.length > 0) { let params: any[] = [];
pkColumn = pkResult[0].attname;
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`; const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
await query<any>(queryText, [id]); console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params);
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
return { return {
success: true, success: true,
}; };
} catch (error) { } catch (error) {
console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error); console.error(`레코드 삭제 오류 (${tableName}):`, error);
return { return {
success: false, success: false,
message: "레코드 삭제 중 오류가 발생했습니다.", message: "레코드 삭제 중 오류가 발생했습니다.",

View File

@ -61,6 +61,7 @@ export interface PersonBean {
partnerObjid?: string; partnerObjid?: string;
authName?: string; authName?: string;
companyCode?: string; companyCode?: string;
companyName?: string; // 회사명 추가
photo?: string; photo?: string;
locale?: string; locale?: string;
// 권한 레벨 정보 (3단계 체계) // 권한 레벨 정보 (3단계 체계)
@ -94,6 +95,7 @@ export interface JwtPayload {
userName: string; userName: string;
deptName?: string; deptName?: string;
companyCode?: string; companyCode?: string;
companyName?: string; // 회사명 추가
userType?: string; userType?: string;
userTypeName?: string; userTypeName?: string;
iat?: number; iat?: number;

View File

@ -17,6 +17,7 @@ export class JwtUtils {
userName: userInfo.userName, userName: userInfo.userName,
deptName: userInfo.deptName, deptName: userInfo.deptName,
companyCode: userInfo.companyCode, companyCode: userInfo.companyCode,
companyName: userInfo.companyName, // 회사명 추가
userType: userInfo.userType, userType: userInfo.userType,
userTypeName: userInfo.userTypeName, userTypeName: userInfo.userTypeName,
}; };
@ -45,6 +46,7 @@ export class JwtUtils {
userName: decoded.userName, userName: decoded.userName,
deptName: decoded.deptName, deptName: decoded.deptName,
companyCode: decoded.companyCode, companyCode: decoded.companyCode,
companyName: decoded.companyName, // 회사명 추가
userType: decoded.userType, userType: decoded.userType,
userTypeName: decoded.userTypeName, userTypeName: decoded.userTypeName,
}; };

View File

@ -183,6 +183,15 @@ body {
background: hsl(var(--background)); background: hsl(var(--background));
} }
/* Button 기본 커서 스타일 */
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
/* ===== Dialog/Modal Overlay ===== */ /* ===== Dialog/Modal Overlay ===== */
/* Radix UI Dialog Overlay - 60% 불투명도 배경 */ /* Radix UI Dialog Overlay - 60% 불투명도 배경 */
[data-radix-dialog-overlay], [data-radix-dialog-overlay],

View File

@ -57,7 +57,7 @@ interface RealtimePreviewProps {
isSelected?: boolean; isSelected?: boolean;
isDesignMode?: boolean; isDesignMode?: boolean;
onClick?: (e?: React.MouseEvent) => void; onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.MouseEvent | React.DragEvent) => void; // MouseEvent도 허용 onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void; onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기 onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
@ -247,13 +247,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const { type, id, position, size, style = {} } = component; const { type, id, position, size, style = {} } = component;
// 🔍 [디버깅] 렌더링 시 크기 로그
console.log("🎨 [RealtimePreview] 렌더링", {
componentId: id,
size,
position,
});
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0); const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
const [actualHeight, setActualHeight] = useState<number | null>(null); const [actualHeight, setActualHeight] = useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
@ -465,17 +458,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onClick?.(e); onClick?.(e);
}; };
const handleMouseDown = (e: React.MouseEvent) => {
// 디자인 모드에서만 드래그 시작 (캔버스 내 이동용)
if (isDesignMode && onDragStart) {
e.stopPropagation();
// MouseEvent를 그대로 전달
onDragStart(e);
}
};
const handleDragStart = (e: React.DragEvent) => { const handleDragStart = (e: React.DragEvent) => {
// HTML5 Drag API (팔레트에서 캔버스로 드래그용)
e.stopPropagation(); e.stopPropagation();
onDragStart?.(e); onDragStart?.(e);
}; };
@ -490,9 +473,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
className="absolute cursor-pointer" className="absolute cursor-pointer"
style={{ ...componentStyle, ...selectionStyle }} style={{ ...componentStyle, ...selectionStyle }}
onClick={handleClick} onClick={handleClick}
onMouseDown={isDesignMode ? handleMouseDown : undefined} draggable
draggable={!isDesignMode} // 디자인 모드가 아닐 때만 draggable (팔레트용) onDragStart={handleDragStart}
onDragStart={!isDesignMode ? handleDragStart : undefined}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* 컴포넌트 타입별 렌더링 */} {/* 컴포넌트 타입별 렌더링 */}

View File

@ -264,9 +264,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
height: getHeight(), height: getHeight(),
zIndex: component.type === "layout" ? 1 : position.z || 2, zIndex: component.type === "layout" ? 1 : position.z || 2,
...componentStyle, ...componentStyle,
// 🔥 중요: componentStyle.width를 덮어쓰기 위해 다시 설정
width: getWidth(), // size.width 기반 픽셀 값으로 강제
height: getHeight(), // size.height 기반 픽셀 값으로 강제
right: undefined, right: undefined,
}; };

View File

@ -29,9 +29,13 @@ import {
snapToGrid, snapToGrid,
snapSizeToGrid, snapSizeToGrid,
generateGridLines, generateGridLines,
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid, alignGroupChildrenToGrid,
calculateOptimalGroupSize, calculateOptimalGroupSize,
normalizeGroupChildPositions, normalizeGroupChildPositions,
calculateWidthFromColumns,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils"; } from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar"; import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { screenApi, tableTypeApi } from "@/lib/api/screen";
@ -103,8 +107,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const [layout, setLayout] = useState<LayoutData>({ const [layout, setLayout] = useState<LayoutData>({
components: [], components: [],
gridSettings: { gridSettings: {
snapToGrid: true, // 격자 스냅 ON columns: 12,
showGrid: false, // 격자 표시 OFF gap: 16,
padding: 0,
snapToGrid: true,
showGrid: false, // 기본값 false로 변경
gridColor: "#d1d5db", gridColor: "#d1d5db",
gridOpacity: 0.5, gridOpacity: 0.5,
}, },
@ -533,31 +540,107 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
gridInfo && gridInfo &&
newComp.type !== "group" newComp.type !== "group"
) { ) {
// 🔥 10px 고정 격자로 스냅 // 현재 해상도에 맞는 격자 정보로 스냅 적용
const currentGridInfo = calculateGridInfo( const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
screenResolution.width, columns: prevLayout.gridSettings.columns,
screenResolution.height, gap: prevLayout.gridSettings.gap,
prevLayout.gridSettings, 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; 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 ( if (
(path === "position.x" || path === "position.y" || path === "position") && (path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid layout.gridSettings?.snapToGrid
) { ) {
// 🔥 10px 고정 격자 // 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = calculateGridInfo( const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
screenResolution.width, columns: layout.gridSettings.columns,
screenResolution.height, gap: layout.gridSettings.gap,
layout.gridSettings, padding: layout.gridSettings.padding,
); snapToGrid: layout.gridSettings.snapToGrid || false,
const snappedPosition = snapToGrid(newComp.position, currentGridInfo, layout.gridSettings); });
newComp.position = snappedPosition;
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
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; return newComp;
@ -820,13 +903,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter"); const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
const convertedComponents = convertLayoutComponents(layoutToUse.components); const convertedComponents = convertLayoutComponents(layoutToUse.components);
// 🔥 10px 고정 격자 시스템으로 자동 마이그레이션 // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
// 이전 columns, gap, padding 설정을 제거하고 새 시스템으로 변환
const layoutWithDefaultGrid = { const layoutWithDefaultGrid = {
...layoutToUse, ...layoutToUse,
components: convertedComponents, // 변환된 컴포넌트 사용 components: convertedComponents, // 변환된 컴포넌트 사용
gridSettings: { 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 값 우선 snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선
showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선 showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선
gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db", gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db",
@ -834,8 +918,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}, },
}; };
console.log("✅ 격자 설정 로드 (10px 고정):", layoutWithDefaultGrid.gridSettings);
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
if (layoutToUse.screenResolution) { if (layoutToUse.screenResolution) {
setScreenResolution(layoutToUse.screenResolution); setScreenResolution(layoutToUse.screenResolution);
@ -992,12 +1074,51 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
}, [MIN_ZOOM, MAX_ZOOM]); }, [MIN_ZOOM, MAX_ZOOM]);
// 격자 설정 업데이트 (10px 고정 격자 - 자동 스냅 제거) // 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback( const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => { (newGridSettings: GridSettings) => {
// 단순히 격자 설정만 업데이트 (컴포넌트 자동 이동 없음)
const newLayout = { ...layout, gridSettings: newGridSettings }; 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); setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
}, },
@ -1094,13 +1215,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns 재계산
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
return { return {
...comp, ...comp,
position: snappedPosition, position: snappedPosition,
size: snappedSize, size: snappedSize,
gridColumns: adjustedGridColumns,
}; };
}); });
console.log("🧲 격자 스냅 적용 완료");
} }
const updatedLayout = { const updatedLayout = {
@ -1159,10 +1285,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings); const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, 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 { return {
...comp, ...comp,
position: snappedPosition, position: snappedPosition,
size: snappedSize, size: snappedSize,
gridColumns: adjustedGridColumns,
}; };
}); });
@ -1321,8 +1454,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
: { x: absoluteX, y: absoluteY, z: 1 }; : { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") { 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 { return {
id: componentId, id: componentId,
@ -1346,11 +1495,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 데이터 테이블 컴포넌트 생성 // 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// 🔥 10px 고정 격자: 기본 크기 사용 // gridColumns에 맞는 크기 계산
const calculatedSize = { const calculatedSize =
width: 800, // 데이터 테이블 기본 너비 currentGridInfo && layout.gridSettings?.snapToGrid
height: templateComp.size.height, ? (() => {
}; const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", { console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns, gridColumns,
@ -1415,11 +1574,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 파일 첨부 컴포넌트 생성 // 파일 첨부 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 const gridColumns = 6; // 기본값: 6컬럼
// 🔥 10px 고정 격자 const calculatedSize =
const calculatedSize = { currentGridInfo && layout.gridSettings?.snapToGrid
width: 400, ? (() => {
height: templateComp.size.height, const newWidth = calculateWidthFromColumns(
}; gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return { return {
id: componentId, id: componentId,
@ -1457,11 +1625,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 영역 컴포넌트 생성 // 영역 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// 🔥 10px 고정 격자 const calculatedSize =
const calculatedSize = { currentGridInfo && layout.gridSettings?.snapToGrid
width: 600, // 영역 기본 너비 ? (() => {
height: templateComp.size.height, const newWidth = calculateWidthFromColumns(
}; gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return { return {
id: componentId, id: componentId,
@ -1583,7 +1760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const widgetSize = const widgetSize =
currentGridInfo && layout.gridSettings?.snapToGrid currentGridInfo && layout.gridSettings?.snapToGrid
? { ? {
width: 200, width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings),
height: templateComp.size.height, height: templateComp.size.height,
} }
: templateComp.size; : templateComp.size;
@ -1954,9 +2131,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}); });
} }
// 🗑️ 10px 고정 격자: gridColumns 로직 제거 // 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
// 기본 크기만 사용 if (layout.gridSettings?.snapToGrid && gridInfo) {
componentSize = component.defaultSize; // 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("🎨 최종 컴포넌트 크기:", { console.log("🎨 최종 컴포넌트 크기:", {
componentId: component.id, componentId: component.id,
@ -2056,12 +2247,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault(); e.preventDefault();
const dragData = e.dataTransfer.getData("application/json"); const dragData = e.dataTransfer.getData("application/json");
// console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) { if (!dragData) {
// console.log("❌ 드래그 데이터가 없습니다");
return; return;
} }
try { try {
const parsedData = JSON.parse(dragData); const parsedData = JSON.parse(dragData);
// console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우 // 템플릿 드래그인 경우
if (parsedData.type === "template") { if (parsedData.type === "template") {
@ -2115,8 +2309,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
} else if (type === "column") { } else if (type === "column") {
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName }); // 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 => { 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%) const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
return defaultColumns; return defaultColumns;
}; };
@ -2326,9 +2547,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입별 적절한 gridColumns 계산 // 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType); const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// 🔥 10px 고정 격자: 간단한 너비 계산 // gridColumns에 맞는 실제 너비 계산
const componentWidth = defaultWidth; const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
newComponent = { newComponent = {
id: generateComponentId(), id: generateComponentId(),
@ -2349,7 +2583,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory, codeCategory: column.codeCategory,
}), }),
style: { style: {
labelDisplay: false, // 라벨 숨김 (placeholder 사용) labelDisplay: false, // 라벨 숨김
labelFontSize: "12px", labelFontSize: "12px",
labelColor: "#212121", labelColor: "#212121",
labelFontWeight: "500", labelFontWeight: "500",
@ -2361,7 +2595,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: column.widgetType, // 원본 웹타입 보존 webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등) inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType), ...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시 placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
column.codeCategory && { column.codeCategory && {
@ -2380,9 +2614,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입별 적절한 gridColumns 계산 // 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType); const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// 🔥 10px 고정 격자: 간단한 너비 계산 // gridColumns에 맞는 실제 너비 계산
const componentWidth = defaultWidth; 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") { // if (column.widgetType === "image") {
@ -2412,7 +2659,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory, codeCategory: column.codeCategory,
}), }),
style: { style: {
labelDisplay: false, // 라벨 숨김 (placeholder 사용) labelDisplay: false, // 라벨 숨김
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정 labelColor: "#000000", // 순수한 검정
labelFontWeight: "500", labelFontWeight: "500",
@ -2424,7 +2671,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: column.widgetType, // 원본 웹타입 보존 webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등) inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType), ...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시 placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
column.codeCategory && { column.codeCategory && {
@ -2456,6 +2703,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings); newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, 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 = { const newLayout = {
@ -2629,10 +2891,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentsToMove = [...componentsToMove, ...additionalComponents]; componentsToMove = [...componentsToMove, ...additionalComponents];
} }
const finalGrabOffset = { // console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
x: relativeMouseX - component.position.x, console.log("마우스 위치 (줌 보정):", {
y: relativeMouseY - component.position.y, 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({ setDragState({
isDragging: true, isDragging: true,
@ -2648,7 +2926,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
y: component.position.y, y: component.position.y,
z: (component.position as Position).z || 1, z: (component.position as Position).z || 1,
}, },
grabOffset: finalGrabOffset, grabOffset: {
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
},
justFinishedDrag: false, justFinishedDrag: false,
}); });
}, },
@ -2676,24 +2957,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const rawX = relativeMouseX - dragState.grabOffset.x; const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y; const rawY = relativeMouseY - dragState.grabOffset.y;
// 🔥 경계 제한 로직 제거: 컴포넌트가 화면을 벗어나도 되게 함
// 이유:
// 1. 큰 컴포넌트(884px)를 작은 영역(16px)에만 제한하는 것은 사용성 문제
// 2. 사용자가 자유롭게 배치할 수 있어야 함
// 3. 최소 위치만 0 이상으로 제한 (음수 좌표 방지)
const newPosition = { const newPosition = {
x: Math.max(0, rawX), x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, rawY), y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
z: (dragState.draggedComponent.position as Position).z || 1, 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) => { setDragState((prev) => {
const newState = { const newState = {
...prev, ...prev,
currentPosition: { ...newPosition }, // 새로운 객체 생성 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; return newState;
}); });
@ -2711,8 +3002,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition; 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) { if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
@ -2723,9 +3021,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
z: dragState.currentPosition.z ?? 1, z: dragState.currentPosition.z ?? 1,
}, },
currentGridInfo, 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, 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 { return {
...comp, ...comp,
position: newPosition as Position, 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)) { if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id); const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id);
if (updatedSelectedComponent) { if (updatedSelectedComponent) {
console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", {
componentId: selectedComponent.id,
oldPosition: selectedComponent.position,
newPosition: updatedSelectedComponent.position,
});
setSelectedComponent(updatedSelectedComponent); setSelectedComponent(updatedSelectedComponent);
} }
} }
@ -4029,7 +4366,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return ( return (
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg"> <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="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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"

View File

@ -1,79 +1,335 @@
"use client";
import React from "react"; import React from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button";
import { Grid3X3 } from "lucide-react"; import { Separator } from "@/components/ui/separator";
import { GridSettings } from "@/types/screen-management"; 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 { interface GridPanelProps {
gridSettings: GridSettings; gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void; onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
screenResolution?: ScreenResolution; // 해상도 정보 추가
} }
/** export const GridPanel: React.FC<GridPanelProps> = ({
* (10px ) gridSettings,
* onGridSettingsChange,
* : onResetGrid,
* - ON/OFF onForceGridUpdate,
* - ON/OFF screenResolution,
* }) => {
* ( ): const updateSetting = (key: keyof GridSettings, value: any) => {
* - 크기: 10px
* - 간격: 10px
*/
export function GridPanel({ gridSettings, onGridSettingsChange }: GridPanelProps) {
const updateSetting = <K extends keyof GridSettings>(key: K, value: GridSettings[K]) => {
onGridSettingsChange({ onGridSettingsChange({
...gridSettings, ...gridSettings,
[key]: value, [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 ( return (
<Card className="w-full"> <div className="flex h-full flex-col">
<CardHeader className="space-y-1 pb-3"> {/* 헤더 */}
<div className="flex items-center gap-2"> <div className="border-b p-4">
<Grid3X3 className="h-4 w-4" /> <div className="mb-3 flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle> <div className="flex items-center gap-2">
</div> <Grid3X3 className="text-muted-foreground h-4 w-4" />
</CardHeader> <h3 className="text-sm font-semibold"> </h3>
<CardContent className="space-y-4"> </div>
{/* 격자 표시 */}
<div className="flex items-center justify-between"> <div className="flex items-center gap-1.5">
<Label htmlFor="showGrid" className="flex items-center gap-2 text-sm font-medium"> {onForceGridUpdate && (
<Grid3X3 className="h-4 w-4 text-muted-foreground" /> <Button
size="sm"
</Label> variant="outline"
<Checkbox onClick={onForceGridUpdate}
id="showGrid" className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
checked={gridSettings.showGrid ?? false} title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
onCheckedChange={(checked) => updateSetting("showGrid", checked as boolean)} >
/> <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>
{/* 격자 스냅 */} {/* 주요 토글들 */}
<div className="flex items-center justify-between"> <div className="space-y-2.5">
<Label htmlFor="snapToGrid" className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center justify-between">
<Grid3X3 className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2">
{gridSettings.showGrid ? (
</Label> <Eye className="text-primary h-3.5 w-3.5" />
<Checkbox ) : (
id="snapToGrid" <EyeOff className="text-muted-foreground h-3.5 w-3.5" />
checked={gridSettings.snapToGrid} )}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked as boolean)} <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>
{/* 격자 정보 (읽기 전용) */} <Separator />
<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"> <div className="space-y-4">
<li> : <span className="font-semibold">10px </span></li> <h4 className="font-medium text-gray-900"> </h4>
<li> 10px </li>
<li> </li> <div>
</ul> <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> </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;

View File

@ -53,16 +53,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
onDragStart, onDragStart,
placedColumns = new Set(), 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) => ({ const tablesWithAvailableColumns = tables.map((table) => ({
...table, ...table,
columns: table.columns.filter((col) => { columns: table.columns.filter((col) => {
const columnKey = `${table.tableName}.${col.columnName}`; const columnKey = `${table.tableName}.${col.columnName}`;
// 기본 컬럼 또는 이미 배치된 컬럼은 제외 // 시스템 컬럼이거나 이미 배치된 컬럼은 제외
return !hiddenColumns.has(col.columnName) && !placedColumns.has(columnKey); return !systemColumns.has(col.columnName.toLowerCase()) && !placedColumns.has(columnKey);
}), }),
})); }));

View File

@ -125,23 +125,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
} }
}, [selectedComponent?.size?.height, selectedComponent?.id]); }, [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 이전에 정의) // 격자 설정 업데이트 함수 (early return 이전에 정의)
const updateGridSetting = (key: string, value: any) => { const updateGridSetting = (key: string, value: any) => {
if (onGridSettingsChange && gridSettings) { if (onGridSettingsChange && gridSettings) {
@ -152,10 +135,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
} }
}; };
// 격자 설정 렌더링 (10px 고정 격자) // 격자 설정 렌더링 (early return 이전에 정의)
const renderGridSettings = () => { const renderGridSettings = () => {
if (!gridSettings || !onGridSettingsChange) return null; 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@ -164,7 +154,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{/* 격자 표시 */} {/* 토글들 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{gridSettings.showGrid ? ( {gridSettings.showGrid ? (
@ -178,12 +168,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
<Checkbox <Checkbox
id="showGrid" id="showGrid"
checked={gridSettings.showGrid ?? false} checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)} onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
/> />
</div> </div>
{/* 격자 스냅 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="text-primary h-3 w-3" /> <Zap className="text-primary h-3 w-3" />
@ -198,14 +187,65 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
/> />
</div> </div>
{/* 격자 정보 (읽기 전용) */} {/* 컬럼 수 */}
<div className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground"> <div className="space-y-1">
<p className="font-medium mb-0.5">🔧 </p> <Label htmlFor="columns" className="text-xs font-medium">
<ul className="space-y-0.5">
<li> : <span className="font-semibold">10px </span></li> </Label>
<li> 10px </li> <div className="flex items-center gap-2">
<li> </li> <Input
</ul> 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> </div>
</div> </div>
@ -415,90 +455,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
)} )}
{/* Z-Index */} {/* Grid Columns + 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>
{/* 크기 (너비/높이) */}
<div className="grid grid-cols-2 gap-2"> <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"> <div className="space-y-1">
<Label className="text-xs"> (px)</Label> <Label className="text-xs">Z-Index</Label>
<Input <Input
type="number" type="number"
step="1" step="1"
value={localSize.width} value={currentPosition.z || 1}
onChange={(e) => { onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
// 입력 중에는 로컬 상태만 업데이트
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();
}
}}
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }} 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" }} style={{ fontSize: "12px" }}
/> />
</div> </div>

View File

@ -83,7 +83,7 @@ export const dataApi = {
*/ */
createRecord: async (tableName: string, data: Record<string, any>): Promise<any> => { createRecord: async (tableName: string, data: Record<string, any>): Promise<any> => {
const response = await apiClient.post(`/data/${tableName}`, data); 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> => { updateRecord: async (tableName: string, id: string | number, data: Record<string, any>): Promise<any> => {
const response = await apiClient.put(`/data/${tableName}/${id}`, data); const response = await apiClient.put(`/data/${tableName}/${id}`, data);
return response.data?.data || response.data; return response.data; // success, data, message 포함된 전체 응답 반환
}, },
/** /**
* *
* @param tableName * @param tableName
* @param id ID * @param id ID
*/ */
deleteRecord: async (tableName: string, id: string | number): Promise<void> => { deleteRecord: async (tableName: string, id: string | number | Record<string, any>): Promise<any> => {
await apiClient.delete(`/data/${tableName}/${id}`); // 복합키 객체인 경우 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 포함된 전체 응답 반환
}, },
}; };

View File

@ -273,7 +273,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
// daterange 타입 전용 UI // daterange 타입 전용 UI
if (webType === "daterange") { if (webType === "daterange") {
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground"> <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( 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", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
@ -325,7 +325,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
} }
}} }}
className={cn( 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", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", 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) // year 타입 전용 UI (number input with YYYY format)
if (webType === "year") { if (webType === "year") {
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground"> <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( 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", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
@ -380,7 +380,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
} }
return ( return (
<div className={`relative w-full ${className || ""}`} {...safeDomProps}> <div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && ( {component.label && component.style?.labelDisplay !== false && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600"> <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} required={componentConfig.required || false}
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false} readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
className={cn( 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", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input", isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",

View File

@ -771,7 +771,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return; 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으로 다운로드 (인증 토큰 포함) // API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
const response = await apiClient.get(`/files/download/${file.objid}`, { const response = await apiClient.get(`/files/download/${file.objid}`, {
@ -792,8 +802,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setRepresentativeImageUrl(url); setRepresentativeImageUrl(url);
console.log("✅ 대표 이미지 로드 성공:", url); console.log("✅ 대표 이미지 로드 성공:", url);
} catch (error) { } catch (error: any) {
console.error("❌ 대표 이미지 로드 실패:", error); console.error("❌ 대표 이미지 로드 실패:", {
file: file.realFileName,
objid: file.objid,
error: error?.response?.status || error?.message,
});
setRepresentativeImageUrl(null); setRepresentativeImageUrl(null);
} }
}, },

View File

@ -9,7 +9,8 @@ import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; 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 { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "./types"; import { SplitPanelLayoutConfig } from "./types";
import { TableInfo, ColumnInfo } from "@/types/screen"; import { TableInfo, ColumnInfo } from "@/types/screen";
@ -74,6 +75,61 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenTableName]); }, [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) => { const loadTableColumns = async (tableName: string) => {
if (loadedTableColumns[tableName] || loadingColumns[tableName]) { 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", required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default, columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
isPrimaryKey: col.isPrimaryKey || false, // PK 여부 추가
codeCategory: col.codeCategory || col.code_category, codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value, codeValue: col.codeValue || col.code_value,
})); }));
@ -139,6 +196,44 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onChange(newConfig); 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 updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
const newConfig = { const newConfig = {
...config, ...config,
@ -190,7 +285,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
console.log(" - availableRightTables:", availableRightTables.length, "개"); console.log(" - availableRightTables:", availableRightTables.length, "개");
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* 관계 타입 선택 */} {/* 관계 타입 선택 */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
@ -230,9 +325,14 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</Select> </Select>
</div> </div>
{/* 좌측 패널 설정 (마스터) */} {/* 좌측 패널 설정 (Accordion) */}
<div className="space-y-4"> <Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
<h3 className="text-sm font-semibold"> ()</h3> <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"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
@ -268,11 +368,464 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })} onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })}
/> />
</div> </div>
</div>
{/* 우측 패널 설정 */} <div className="flex items-center justify-between">
<div className="space-y-4"> <Label> + </Label>
<h3 className="text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조인"})</h3> <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"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
@ -467,11 +1020,370 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })} onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })}
/> />
</div> </div>
</div>
{/* 레이아웃 설정 */} {/* 우측 패널 표시 컬럼 설정 */}
<div className="space-y-4"> <div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
<h3 className="text-sm font-semibold"> </h3> <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"> <div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label> <Label> : {config.splitRatio || 30}%</Label>
@ -499,7 +1411,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })} onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/> />
</div> </div>
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

View File

@ -10,11 +10,34 @@ export interface SplitPanelLayoutConfig {
dataSource?: string; // API 엔드포인트 dataSource?: string; // API 엔드포인트
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼
columns?: Array<{ columns?: Array<{
name: string; name: string;
label: string; label: string;
width?: number; 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; dataSource?: string;
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼
columns?: Array<{ columns?: Array<{
name: string; name: string;
label: string; label: string;
width?: number; width?: number;
}>; }>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
name: string;
label: string;
required?: boolean;
}>;
// 좌측 선택 항목과의 관계 설정 // 좌측 선택 항목과의 관계 설정
relation?: { relation?: {
type: "join" | "detail"; // 관계 타입 type: "join" | "detail"; // 관계 타입
leftColumn?: string; // 좌측 테이블의 연결 컬럼 leftColumn?: string; // 좌측 테이블의 연결 컬럼
rightColumn?: string; // 우측 테이블의 연결 컬럼 (join용)
foreignKey?: string; // 우측 테이블의 외래키 컬럼명 foreignKey?: string; // 우측 테이블의 외래키 컬럼명
}; };
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
addConfig?: {
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
};
}; };
// 레이아웃 설정 // 레이아웃 설정

View File

@ -1,77 +1,205 @@
import { Position, Size } from "@/types/screen"; import { Position, Size } from "@/types/screen";
import { GridSettings } from "@/types/screen-management"; import { GridSettings } from "@/types/screen-management";
// 🎯 10px 고정 격자 시스템
const GRID_SIZE = 10; // 고정값
export interface GridInfo { export interface GridInfo {
gridSize: number; // 항상 10px columnWidth: number;
totalWidth: number; totalWidth: number;
totalHeight: number; totalHeight: number;
} }
/** /**
* () *
*/ */
export function calculateGridInfo( export function calculateGridInfo(
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
_gridSettings?: GridSettings, // 호환성 유지용 (사용 안 함) gridSettings: GridSettings,
): GridInfo { ): 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 { return {
gridSize: GRID_SIZE, columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
totalWidth: containerWidth, totalWidth: containerWidth,
totalHeight: containerHeight, 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) { if (!gridSettings.snapToGrid) {
return position; 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 { return {
x: Math.round(position.x / GRID_SIZE) * GRID_SIZE, x: snappedX,
y: Math.round(position.y / GRID_SIZE) * GRID_SIZE, y: snappedY,
z: position.z, 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) { if (!gridSettings.snapToGrid) {
return size; 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 { return {
width: Math.max(GRID_SIZE, Math.round(size.width / GRID_SIZE) * GRID_SIZE), width: Math.max(columnWidth, snappedWidth),
height: Math.max(GRID_SIZE, Math.round(size.height / GRID_SIZE) * GRID_SIZE), 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( export function generateGridLines(
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
_gridSettings?: GridSettings, gridSettings: GridSettings,
): { ): {
verticalLines: number[]; verticalLines: number[];
horizontalLines: 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[] = []; const verticalLines: number[] = [];
for (let x = 0; x <= containerWidth; x += GRID_SIZE) { for (let i = 0; i <= columns; i++) {
verticalLines.push(x); const x = padding + i * cellWidth;
if (x <= containerWidth) {
verticalLines.push(x);
}
} }
// 가로 격자선
const horizontalLines: number[] = []; const horizontalLines: number[] = [];
for (let y = 0; y <= containerHeight; y += GRID_SIZE) { for (let y = padding; y < containerHeight; y += cellHeight) {
horizontalLines.push(y); horizontalLines.push(y);
} }
@ -114,21 +242,46 @@ export function alignGroupChildrenToGrid(
): any[] { ): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children; if (!gridSettings.snapToGrid || children.length === 0) return children;
return children.map((child) => { console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
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 padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// 10px 단위로 스냅 // Y 좌표는 10px 단위로 스냅
const snappedX = Math.max(padding, Math.round((child.position.x - padding) / GRID_SIZE) * GRID_SIZE + padding); const rowHeight = 10;
const snappedY = Math.max(padding, Math.round((child.position.y - padding) / GRID_SIZE) * GRID_SIZE + padding); const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
const snappedWidth = Math.max(GRID_SIZE, Math.round(child.size.width / GRID_SIZE) * GRID_SIZE); // 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const snappedHeight = Math.max(GRID_SIZE, Math.round(child.size.height / GRID_SIZE) * GRID_SIZE); 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);
return { const snappedChild = {
...child, ...child,
position: { position: {
x: snappedX, x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: snappedY, y: Math.max(padding, snappedY),
z: child.position.z || 1, z: child.position.z || 1,
}, },
size: { size: {
@ -136,6 +289,26 @@ export function alignGroupChildrenToGrid(
height: snappedHeight, 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( export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>, children: Array<{ position: Position; size: Size }>,
_gridInfo?: GridInfo, gridInfo: GridInfo,
_gridSettings?: GridSettings, gridSettings: GridSettings,
): Size { ): Size {
if (children.length === 0) { 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( const bounds = children.reduce(
(acc, child) => ({ (acc, child) => ({
minX: Math.min(acc.minX, child.position.x), minX: Math.min(acc.minX, child.position.x),
@ -161,38 +340,61 @@ export function calculateOptimalGroupSize(
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX; const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY; const contentHeight = bounds.maxY - bounds.minY;
const padding = 16;
return { // 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2, width: contentWidth + padding * 2,
height: contentHeight + 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[] { export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
if (children.length === 0) return children; 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 minX = Math.min(...children.map((child) => child.position.x));
const minY = Math.min(...children.map((child) => child.position.y)); 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, ...child,
position: { position: {
x: child.position.x - minX + padding, x: child.position.x - minX + startX,
y: child.position.y - minY + padding, y: child.position.y - minY + startY,
z: child.position.z || 1, z: child.position.z || 1,
}, },
})); }));
}
// 🗑️ 제거된 함수들 (더 이상 필요 없음) console.log("✅ 정규화 완료:", {
// - calculateWidthFromColumns normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
// - updateSizeFromGridColumns });
// - adjustGridColumnsFromSize
// - calculateColumnsFromWidth return normalizedChildren;
}

View File

@ -561,22 +561,21 @@ export interface LayoutData {
} }
/** /**
* (10px ) *
*/ */
export interface GridSettings { export interface GridSettings {
snapToGrid: boolean; // 격자 스냅 ON/OFF enabled: boolean;
showGrid?: boolean; // 격자 표시 여부 size: number;
gridColor?: string; // 격자 선 색상 color: string;
gridOpacity?: number; // 격자 선 투명도 opacity: number;
snapToGrid: boolean;
// 🗑️ 제거된 속성들 (10px 고정으로 더 이상 필요 없음) // gridUtils에서 필요한 속성들 추가
// - columns: 자동 계산 (해상도 ÷ 10px) columns: number;
// - gap: 10px 고정 gap: number;
// - padding: 0px 고정 padding: number;
// - size: 10px 고정 showGrid?: boolean;
// - enabled: showGrid로 대체 gridColor?: string;
// - color: gridColor로 대체 gridOpacity?: number;
// - opacity: gridOpacity로 대체
} }
/** /**