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

3
.gitignore vendored
View File

@ -286,5 +286,4 @@ uploads/
*.hwp
*.hwpx
claude.md
.cursor/rules/ai-developer-collaboration-rules.mdc
claude.md

View File

@ -51,21 +51,26 @@ router.get(
}
}
// 회사 코드 추출 (멀티테넌시 필터링)
const userCompany = req.user?.companyCode;
console.log(`🔗 조인 데이터 조회:`, {
leftTable,
rightTable,
leftColumn,
rightColumn,
leftValue,
userCompany,
});
// 조인 데이터 조회
// 조인 데이터 조회 (회사 코드 전달)
const result = await dataService.getJoinedData(
leftTable as string,
rightTable as string,
leftColumn as string,
rightColumn as string,
leftValue as string
leftValue as string,
userCompany
);
if (!result.success) {
@ -352,8 +357,25 @@ router.post(
console.log(` 레코드 생성: ${tableName}`, data);
// company_code와 company_name 자동 추가 (멀티테넌시)
const enrichedData = { ...data };
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code");
if (hasCompanyCode && req.user?.companyCode) {
enrichedData.company_code = req.user.companyCode;
console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`);
}
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name");
if (hasCompanyName && req.user?.companyName) {
enrichedData.company_name = req.user.companyName;
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
}
// 레코드 생성
const result = await dataService.createRecord(tableName, data);
const result = await dataService.createRecord(tableName, enrichedData);
if (!result.success) {
return res.status(400).json(result);
@ -437,6 +459,58 @@ router.put(
* API
* DELETE /api/data/{tableName}/{id}
*/
/**
* API (POST)
* POST /api/data/:tableName/delete
* Body: { user_id: 'xxx', dept_code: 'yyy' }
*/
router.post(
"/:tableName/delete",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName } = req.params;
const compositeKey = req.body;
// 입력값 검증
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
error: "INVALID_TABLE_NAME",
});
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`🗑️ 복합키 레코드 삭제: ${tableName}`, compositeKey);
// 레코드 삭제 (복합키 객체 전달)
const result = await dataService.deleteRecord(tableName, compositeKey);
if (!result.success) {
return res.status(400).json(result);
}
console.log(`✅ 레코드 삭제 성공: ${tableName}`);
return res.json(result);
} catch (error: any) {
console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error);
return res.status(500).json({
success: false,
message: "레코드 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
router.delete(
"/:tableName/:id",
authenticateToken,

View File

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

View File

@ -231,6 +231,9 @@ class DataService {
const columns = await this.getTableColumnsSimple(tableName);
// PK 컬럼 정보 조회
const pkColumns = await this.getPrimaryKeyColumns(tableName);
// 컬럼 라벨 정보 추가
const columnsWithLabels = await Promise.all(
columns.map(async (column) => {
@ -244,6 +247,7 @@ class DataService {
dataType: column.data_type,
isNullable: column.is_nullable === "YES",
defaultValue: column.column_default,
isPrimaryKey: pkColumns.includes(column.column_name), // PK 여부 추가
};
})
);
@ -262,6 +266,26 @@ class DataService {
}
}
/**
* Primary Key
*/
private async getPrimaryKeyColumns(tableName: string): Promise<string[]> {
try {
const result = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[tableName]
);
return result.map((row) => row.attname);
} catch (error) {
console.error(`PK 컬럼 조회 오류 (${tableName}):`, error);
return [];
}
}
/**
*
*/
@ -286,7 +310,7 @@ class DataService {
/**
*
*/
private async checkColumnExists(
async checkColumnExists(
tableName: string,
columnName: string
): Promise<boolean> {
@ -409,7 +433,8 @@ class DataService {
rightTable: string,
leftColumn: string,
rightColumn: string,
leftValue?: string | number
leftValue?: string | number,
userCompany?: string
): Promise<ServiceResponse<any[]>> {
try {
// 왼쪽 테이블 접근 검증
@ -425,18 +450,42 @@ class DataService {
}
let queryText = `
SELECT r.*
SELECT DISTINCT r.*
FROM "${rightTable}" r
INNER JOIN "${leftTable}" l
ON l."${leftColumn}" = r."${rightColumn}"
`;
const values: any[] = [];
const whereConditions: string[] = [];
let paramIndex = 1;
// 좌측 값 필터링
if (leftValue !== undefined && leftValue !== null) {
queryText += ` WHERE l."${leftColumn}" = $1`;
whereConditions.push(`l."${leftColumn}" = $${paramIndex}`);
values.push(leftValue);
paramIndex++;
}
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
if (hasCompanyCode) {
whereConditions.push(`r.company_code = $${paramIndex}`);
values.push(userCompany);
paramIndex++;
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
}
}
// WHERE 절 추가
if (whereConditions.length > 0) {
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
}
console.log("🔍 조인 쿼리 실행:", queryText);
console.log("📊 조인 쿼리 파라미터:", values);
const result = await query<any>(queryText, values);
return {
@ -512,6 +561,11 @@ class DataService {
return validation.error!;
}
// _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo;
const cleanData = { ...data };
delete cleanData._relationInfo;
// Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
@ -526,8 +580,8 @@ class DataService {
pkColumn = pkResult[0].attname;
}
const columns = Object.keys(data);
const values = Object.values(data);
const columns = Object.keys(cleanData);
const values = Object.values(cleanData);
const setClause = columns
.map((col, index) => `"${col}" = $${index + 1}`)
.join(", ");
@ -550,6 +604,35 @@ class DataService {
};
}
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
const newLeftValue = cleanData[leftColumn];
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
if (newLeftValue !== undefined && newLeftValue !== oldLeftValue) {
console.log("🔗 조인 관계 FK 업데이트:", {
rightTable,
rightColumn,
oldValue: oldLeftValue,
newValue: newLeftValue,
});
try {
const updateRelatedQuery = `
UPDATE "${rightTable}"
SET "${rightColumn}" = $1
WHERE "${rightColumn}" = $2
`;
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
} catch (relError) {
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
}
}
}
return {
success: true,
data: result[0],
@ -569,7 +652,7 @@ class DataService {
*/
async deleteRecord(
tableName: string,
id: string | number
id: string | number | Record<string, any>
): Promise<ServiceResponse<void>> {
try {
// 테이블 접근 검증
@ -578,28 +661,53 @@ class DataService {
return validation.error!;
}
// Primary Key 컬럼 찾기
// Primary Key 컬럼 찾기 (복합키 지원)
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
WHERE i.indrelid = $1::regclass AND i.indisprimary
ORDER BY a.attnum`,
[tableName]
);
let pkColumn = "id";
if (pkResult.length > 0) {
pkColumn = pkResult[0].attname;
let whereClauses: string[] = [];
let params: any[] = [];
if (pkResult.length > 1) {
// 복합키인 경우: id가 객체여야 함
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
if (typeof id === 'object' && !Array.isArray(id)) {
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
pkResult.forEach((pk, index) => {
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
params.push(id[pk.attname]);
});
} else {
// id가 문자열/숫자인 경우: 첫 번째 PK만 사용 (하위 호환성)
whereClauses.push(`"${pkResult[0].attname}" = $1`);
params.push(id);
}
} else {
// 단일키인 경우
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
whereClauses.push(`"${pkColumn}" = $1`);
params.push(typeof id === 'object' ? id[pkColumn] : id);
}
const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await query<any>(queryText, [id]);
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params);
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
return {
success: true,
};
} catch (error) {
console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error);
console.error(`레코드 삭제 오류 (${tableName}):`, error);
return {
success: false,
message: "레코드 삭제 중 오류가 발생했습니다.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,9 +29,13 @@ import {
snapToGrid,
snapSizeToGrid,
generateGridLines,
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
calculateWidthFromColumns,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
@ -103,8 +107,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: {
snapToGrid: true, // 격자 스냅 ON
showGrid: false, // 격자 표시 OFF
columns: 12,
gap: 16,
padding: 0,
snapToGrid: true,
showGrid: false, // 기본값 false로 변경
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
@ -533,31 +540,107 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
gridInfo &&
newComp.type !== "group"
) {
// 🔥 10px 고정 격자로 스냅
const currentGridInfo = calculateGridInfo(
screenResolution.width,
screenResolution.height,
prevLayout.gridSettings,
// 현재 해상도에 맞는 격자 정보로 스냅 적용
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
const snappedSize = snapSizeToGrid(
newComp.size,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, prevLayout.gridSettings);
newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(
newComp,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
}
}
// 🗑️ gridColumns 로직 제거: 10px 고정 격자에서는 불필요
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
// gridColumns에 맞는 정확한 너비 계산
const newWidth = calculateWidthFromColumns(
newComp.gridColumns,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = {
...newComp.size,
width: newWidth,
};
}
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
(path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid
) {
// 🔥 10px 고정 격자
const currentGridInfo = calculateGridInfo(
screenResolution.width,
screenResolution.height,
layout.gridSettings,
);
const snappedPosition = snapToGrid(newComp.position, currentGridInfo, layout.gridSettings);
newComp.position = snappedPosition;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (newComp.parentId && currentGridInfo) {
const { columnWidth } = currentGridInfo;
const { gap } = layout.gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = newComp.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
// Y 좌표는 10px 단위로 스냅
const effectiveY = newComp.position.y - padding;
const rowIndex = Math.round(effectiveY / 10);
const snappedY = padding + rowIndex * 10;
// 크기도 외부 격자와 동일하게 스냅
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
// 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거)
const snappedHeight = Math.max(10, newComp.size.height);
newComp.position = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newComp.position.z || 1,
};
newComp.size = {
width: snappedWidth,
height: snappedHeight,
};
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(
newComp.position,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
newComp.position = snappedPosition;
}
}
return newComp;
@ -752,7 +835,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// 🔍 이미지 타입 디버깅
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
// console.log("🖼️ 이미지 컬럼 발견:", {
@ -762,7 +845,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// rawData: col,
// });
// }
return {
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
@ -820,21 +903,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter");
const convertedComponents = convertLayoutComponents(layoutToUse.components);
// 🔥 10px 고정 격자 시스템으로 자동 마이그레이션
// 이전 columns, gap, padding 설정을 제거하고 새 시스템으로 변환
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
const layoutWithDefaultGrid = {
...layoutToUse,
components: convertedComponents, // 변환된 컴포넌트 사용
gridSettings: {
// 🗑️ 제거: columns, gap, padding (더 이상 사용하지 않음)
columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12
gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16
padding: 0, // padding은 항상 0으로 강제
snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선
showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선
gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db",
gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5,
},
};
console.log("✅ 격자 설정 로드 (10px 고정):", layoutWithDefaultGrid.gridSettings);
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
if (layoutToUse.screenResolution) {
@ -992,12 +1074,51 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
}, [MIN_ZOOM, MAX_ZOOM]);
// 격자 설정 업데이트 (10px 고정 격자 - 자동 스냅 제거)
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
// 단순히 격자 설정만 업데이트 (컴포넌트 자동 이동 없음)
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid,
};
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
};
});
newLayout.components = adjustedComponents;
// console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
// console.log("새로운 격자 정보:", newGridInfo);
}
setLayout(newLayout);
saveToHistory(newLayout);
},
@ -1094,13 +1215,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns 재계산
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
console.log("🧲 격자 스냅 적용 완료");
}
const updatedLayout = {
@ -1159,10 +1285,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
@ -1321,8 +1454,24 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
// 🔥 10px 고정 격자: 기본 너비 사용
const calculatedSize = { width: 400, height: templateComp.size.height };
// 그리드 컬럼 기반 크기 계산
const gridColumns =
typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: { width: 400, height: templateComp.size.height }; // 폴백 크기
return {
id: componentId,
@ -1346,11 +1495,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// 🔥 10px 고정 격자: 기본 크기 사용
const calculatedSize = {
width: 800, // 데이터 테이블 기본 너비
height: templateComp.size.height,
};
// gridColumns에 맞는 크기 계산
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
@ -1415,11 +1574,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 파일 첨부 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼
// 🔥 10px 고정 격자
const calculatedSize = {
width: 400,
height: templateComp.size.height,
};
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
@ -1457,11 +1625,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 영역 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// 🔥 10px 고정 격자
const calculatedSize = {
width: 600, // 영역 기본 너비
height: templateComp.size.height,
};
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
@ -1583,7 +1760,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const widgetSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? {
width: 200,
width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings),
height: templateComp.size.height,
}
: templateComp.size;
@ -1788,16 +1965,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산
// 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center)
// 2. 캔버스가 justify-center로 중앙 정렬되어 있음
// 실제 캔버스 논리적 크기
const canvasLogicalWidth = screenResolution.width;
// 화면상 캔버스 실제 크기 (스케일 적용 후)
const canvasVisualWidth = canvasLogicalWidth * zoomLevel;
// 중앙 정렬로 인한 왼쪽 오프셋 계산
// rect.left는 이미 중앙 정렬된 위치를 반영하고 있음
// 마우스의 캔버스 내 상대 위치 (스케일 보정)
const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel;
const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel;
@ -1954,9 +2131,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
}
// 🗑️ 10px 고정 격자: gridColumns 로직 제거
// 기본 크기만 사용
componentSize = component.defaultSize;
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
if (layout.gridSettings?.snapToGrid && gridInfo) {
// gridColumns에 맞는 정확한 너비 계산
const calculatedWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
// 컴포넌트별 최소 크기 보장
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
componentSize = {
...component.defaultSize,
width: Math.max(calculatedWidth, minWidth),
};
}
console.log("🎨 최종 컴포넌트 크기:", {
componentId: component.id,
@ -2056,12 +2247,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
// console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) {
// console.log("❌ 드래그 데이터가 없습니다");
return;
}
try {
const parsedData = JSON.parse(dragData);
// console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
@ -2115,8 +2309,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
} else if (type === "column") {
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
// 🔥 10px 고정 격자 시스템: 간단한 기본 너비 사용
const defaultWidth = 200; // 기본 너비 200px
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
const defaultWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
: 200;
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
gridSettings: layout.gridSettings,
currentGridInfo: currentGridInfo
? {
columnWidth: currentGridInfo.columnWidth.toFixed(2),
totalWidth: currentGridInfo.totalWidth,
}
: null,
defaultWidth: defaultWidth.toFixed(2),
snapToGrid: layout.gridSettings?.snapToGrid,
});
// 웹타입별 기본 그리드 컬럼 수 계산
const getDefaultGridColumns = (widgetType: string): number => {
@ -2155,6 +2375,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
return defaultColumns;
};
@ -2326,9 +2547,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// 🔥 10px 고정 격자: 간단한 너비 계산
const componentWidth = defaultWidth;
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
newComponent = {
id: generateComponentId(),
@ -2349,7 +2583,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 라벨 숨김 (placeholder 사용)
labelDisplay: false, // 라벨 숨김
labelFontSize: "12px",
labelColor: "#212121",
labelFontWeight: "500",
@ -2361,7 +2595,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -2380,9 +2614,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// 🔥 10px 고정 격자: 간단한 너비 계산
const componentWidth = defaultWidth;
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 캔버스 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
// 🔍 이미지 타입 드래그앤드롭 디버깅
// if (column.widgetType === "image") {
@ -2412,7 +2659,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 라벨 숨김 (placeholder 사용)
labelDisplay: false, // 라벨 숨김
labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정
labelFontWeight: "500",
@ -2424,7 +2671,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType),
placeholder: column.columnLabel || column.columnName, // placeholder에 컬럼 라벨명 표시
placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -2456,6 +2703,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
resolution: `${screenResolution.width}x${screenResolution.height}`,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
columnWidth: currentGridInfo.columnWidth,
});
}
if (newComponent.type === "group") {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
type: newComponent.type,
position: newComponent.position,
size: newComponent.size,
});
}
const newLayout = {
@ -2629,11 +2891,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentsToMove = [...componentsToMove, ...additionalComponents];
}
const finalGrabOffset = {
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
};
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
console.log("마우스 위치 (줌 보정):", {
zoomLevel,
clientX: event.clientX,
clientY: event.clientY,
rectLeft: rect.left,
rectTop: rect.top,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
componentX: component.position.x,
componentY: component.position.y,
grabOffsetX: relativeMouseX - component.position.x,
grabOffsetY: relativeMouseY - component.position.y,
});
console.log("🚀 드래그 시작:", {
componentId: component.id,
componentType: component.type,
initialPosition: { x: component.position.x, y: component.position.y },
});
setDragState({
isDragging: true,
draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준)
@ -2648,7 +2926,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
y: component.position.y,
z: (component.position as Position).z || 1,
},
grabOffset: finalGrabOffset,
grabOffset: {
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
},
justFinishedDrag: false,
});
},
@ -2676,24 +2957,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y;
// 🔥 경계 제한 로직 제거: 컴포넌트가 화면을 벗어나도 되게 함
// 이유:
// 1. 큰 컴포넌트(884px)를 작은 영역(16px)에만 제한하는 것은 사용성 문제
// 2. 사용자가 자유롭게 배치할 수 있어야 함
// 3. 최소 위치만 0 이상으로 제한 (음수 좌표 방지)
const newPosition = {
x: Math.max(0, rawX),
y: Math.max(0, rawY),
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
z: (dragState.draggedComponent.position as Position).z || 1,
};
// 드래그 상태 업데이트
console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", {
zoomLevel,
draggedComponentId: dragState.draggedComponent.id,
mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top },
mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY },
oldPosition: dragState.currentPosition,
newPosition: newPosition,
});
setDragState((prev) => {
const newState = {
...prev,
currentPosition: { ...newPosition }, // 새로운 객체 생성
};
console.log("🔄 ScreenDesigner dragState 업데이트:", {
prevPosition: prev.currentPosition,
newPosition: newState.currentPosition,
stateChanged:
prev.currentPosition.x !== newState.currentPosition.x ||
prev.currentPosition.y !== newState.currentPosition.y,
});
return newState;
});
@ -2711,8 +3002,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
// 🔥 10px 고정 격자 시스템: calculateGridInfo는 columns, gap, padding을 무시함
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, layout.gridSettings);
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
@ -2723,9 +3021,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
z: dragState.currentPosition.z ?? 1,
},
currentGridInfo,
layout.gridSettings,
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
console.log("🎯 격자 스냅 적용됨:", {
componentType: draggedComponent?.type,
resolution: `${screenResolution.width}x${screenResolution.height}`,
originalPosition: dragState.currentPosition,
snappedPosition: finalPosition,
columnWidth: currentGridInfo.columnWidth,
});
}
// 스냅으로 인한 추가 이동 거리 계산
@ -2790,6 +3100,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
height: snappedHeight,
};
console.log("🎯 드래그 종료 시 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId: comp.id,
parentId: comp.parentId,
beforeSnap: {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
},
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
afterSnap: newPosition,
afterSizeSnap: newSize,
});
return {
...comp,
position: newPosition as Position,
@ -2812,6 +3144,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id);
if (updatedSelectedComponent) {
console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", {
componentId: selectedComponent.id,
oldPosition: selectedComponent.position,
newPosition: updatedSelectedComponent.position,
});
setSelectedComponent(updatedSelectedComponent);
}
}
@ -4029,7 +4366,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return (
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
<div className="flex flex-col gap-2 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"

View File

@ -1,79 +1,335 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Grid3X3 } from "lucide-react";
import { GridSettings } from "@/types/screen-management";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
import { GridSettings, ScreenResolution } from "@/types/screen";
import { calculateGridInfo } from "@/lib/utils/gridUtils";
interface GridPanelProps {
gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
screenResolution?: ScreenResolution; // 해상도 정보 추가
}
/**
* (10px )
*
* :
* - ON/OFF
* - ON/OFF
*
* ( ):
* - 크기: 10px
* - 간격: 10px
*/
export function GridPanel({ gridSettings, onGridSettingsChange }: GridPanelProps) {
const updateSetting = <K extends keyof GridSettings>(key: K, value: GridSettings[K]) => {
export const GridPanel: React.FC<GridPanelProps> = ({
gridSettings,
onGridSettingsChange,
onResetGrid,
onForceGridUpdate,
screenResolution,
}) => {
const updateSetting = (key: keyof GridSettings, value: any) => {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
};
// 최대 컬럼 수 계산 (최소 컬럼 너비 30px 기준)
const MIN_COLUMN_WIDTH = 30;
const maxColumns = screenResolution
? Math.floor((screenResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
// 실제 격자 정보 계산
const actualGridInfo = screenResolution
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: gridSettings.columns,
gap: gridSettings.gap,
padding: gridSettings.padding,
snapToGrid: gridSettings.snapToGrid || false,
})
: null;
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
const actualColumns = gridSettings.columns;
// 컬럼이 너무 작은지 확인
const isColumnsTooSmall =
screenResolution && actualGridInfo
? actualGridInfo.columnWidth < MIN_COLUMN_WIDTH
: false;
return (
<Card className="w-full">
<CardHeader className="space-y-1 pb-3">
<div className="flex items-center gap-2">
<Grid3X3 className="h-4 w-4" />
<CardTitle className="text-base"> </CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 격자 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="showGrid" className="flex items-center gap-2 text-sm font-medium">
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
</Label>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid ?? false}
onCheckedChange={(checked) => updateSetting("showGrid", checked as boolean)}
/>
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Grid3X3 className="text-muted-foreground h-4 w-4" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<div className="flex items-center gap-1.5">
{onForceGridUpdate && (
<Button
size="sm"
variant="outline"
onClick={onForceGridUpdate}
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
)}
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 격자 스냅 */}
<div className="flex items-center justify-between">
<Label htmlFor="snapToGrid" className="flex items-center gap-2 text-sm font-medium">
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
</Label>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked as boolean)}
/>
{/* 주요 토글들 */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{gridSettings.showGrid ? (
<Eye className="text-primary h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
<Label htmlFor="showGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateSetting("showGrid", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="text-primary h-3.5 w-3.5" />
<Label htmlFor="snapToGrid" className="text-xs font-medium">
</Label>
</div>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked)}
/>
</div>
</div>
</div>
{/* 설정 영역 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 격자 구조 */}
<div className="space-y-3">
<h4 className="text-xs font-semibold"> </h4>
<div className="space-y-2">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={safeMaxColumns}
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
updateSetting("columns", value);
}
}}
className="h-8 text-xs"
/>
<span className="text-muted-foreground text-xs">/ {safeMaxColumns}</span>
</div>
<Slider
id="columns-slider"
min={1}
max={safeMaxColumns}
step={1}
value={[gridSettings.columns]}
onValueChange={([value]) => updateSetting("columns", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>1</span>
<span>{safeMaxColumns}</span>
</div>
{isColumnsTooSmall && (
<p className="text-xs text-amber-600">
( {MIN_COLUMN_WIDTH}px )
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateSetting("gap", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>0px</span>
<span>40px</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateSetting("padding", value)}
className="w-full"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>0px</span>
<span>60px</span>
</div>
</div>
</div>
{/* 격자 정보 (읽기 전용) */}
<div className="mt-4 rounded-md bg-muted p-3 text-xs text-muted-foreground">
<p className="font-medium mb-1">🔧 </p>
<ul className="space-y-0.5">
<li> : <span className="font-semibold">10px </span></li>
<li> 10px </li>
<li> </li>
</ul>
<Separator />
{/* 격자 스타일 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<div>
<Label htmlFor="gridColor" className="text-sm font-medium">
</Label>
<div className="mt-1 flex items-center space-x-2">
<Input
id="gridColor"
type="color"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
className="h-8 w-12 rounded border p-1"
/>
<Input
type="text"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
placeholder="#d1d5db"
className="flex-1"
/>
</div>
</div>
<div>
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
</Label>
<Slider
id="gridOpacity"
min={0.1}
max={1}
step={0.1}
value={[gridSettings.gridOpacity || 0.5]}
onValueChange={([value]) => updateSetting("gridOpacity", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>10%</span>
<span>100%</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Separator />
{/* 미리보기 */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900"></h4>
<div
className="rounded-md border border-gray-200 bg-white p-4"
style={{
backgroundImage: gridSettings.showGrid
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
: "none",
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
opacity: gridSettings.gridOpacity || 0.5,
}}
>
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
<span className="text-primary text-xs"> </span>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-muted-foreground text-xs">💡 </div>
{/* 해상도 및 격자 정보 */}
{screenResolution && actualGridInfo && (
<>
<Separator />
<div className="space-y-3">
<h4 className="font-medium text-gray-900"> </h4>
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono">
{screenResolution.width} × {screenResolution.height}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className={`font-mono ${isColumnsTooSmall ? "text-destructive" : "text-gray-900"}`}>
{actualGridInfo.columnWidth.toFixed(1)}px
{isColumnsTooSmall && " (너무 작음)"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-mono">
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
</span>
</div>
{isColumnsTooSmall && (
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
💡 . .
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
);
}
};
export default GridPanel;

View File

@ -53,16 +53,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
onDragStart,
placedColumns = new Set(),
}) => {
// 숨길 기본 컬럼 목록 (id, created_date, updated_date, writer, company_code)
const hiddenColumns = new Set(['id', 'created_date', 'updated_date', 'writer', 'company_code']);
// 이미 배치된 컬럼 + 기본 컬럼을 제외한 테이블 정보 생성
// 시스템 컬럼 목록 (숨김 처리)
const systemColumns = new Set([
'id',
'created_date',
'updated_date',
'writer',
'company_code'
]);
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
const tablesWithAvailableColumns = tables.map((table) => ({
...table,
columns: table.columns.filter((col) => {
const columnKey = `${table.tableName}.${col.columnName}`;
// 기본 컬럼 또는 이미 배치된 컬럼은 제외
return !hiddenColumns.has(col.columnName) && !placedColumns.has(columnKey);
// 시스템 컬럼이거나 이미 배치된 컬럼은 제외
return !systemColumns.has(col.columnName.toLowerCase()) && !placedColumns.has(columnKey);
}),
}));

View File

@ -125,23 +125,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}, [selectedComponent?.size?.height, selectedComponent?.id]);
// 🔥 훅은 항상 최상단에 (early return 이전)
// 크기 입력 필드용 로컬 상태
const [localSize, setLocalSize] = useState({
width: selectedComponent?.size?.width || 100,
height: selectedComponent?.size?.height || 40,
});
// 선택된 컴포넌트가 변경되면 로컬 상태 동기화
useEffect(() => {
if (selectedComponent) {
setLocalSize({
width: selectedComponent.size?.width || 100,
height: selectedComponent.size?.height || 40,
});
}
}, [selectedComponent?.id, selectedComponent?.size?.width, selectedComponent?.size?.height]);
// 격자 설정 업데이트 함수 (early return 이전에 정의)
const updateGridSetting = (key: string, value: any) => {
if (onGridSettingsChange && gridSettings) {
@ -152,10 +135,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
};
// 격자 설정 렌더링 (10px 고정 격자)
// 격자 설정 렌더링 (early return 이전에 정의)
const renderGridSettings = () => {
if (!gridSettings || !onGridSettingsChange) return null;
// 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
@ -164,7 +154,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
<div className="space-y-3">
{/* 격자 표시 */}
{/* 토글들 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{gridSettings.showGrid ? (
@ -178,12 +168,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid ?? false}
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
/>
</div>
{/* 격자 스냅 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="text-primary h-3 w-3" />
@ -198,14 +187,65 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
/>
</div>
{/* 격자 정보 (읽기 전용) */}
<div className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground">
<p className="font-medium mb-0.5">🔧 </p>
<ul className="space-y-0.5">
<li> : <span className="font-semibold">10px </span></li>
<li> 10px </li>
<li> </li>
</ul>
{/* 컬럼 수 */}
<div className="space-y-1">
<Label htmlFor="columns" className="text-xs font-medium">
</Label>
<div className="flex items-center gap-2">
<Input
id="columns"
type="number"
min={1}
max={safeMaxColumns}
step="1"
value={gridSettings.columns}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= safeMaxColumns) {
updateGridSetting("columns", value);
}
}}
className="h-6 px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
placeholder={`1~${safeMaxColumns}`}
/>
</div>
<p className="text-muted-foreground text-[10px]">
{safeMaxColumns} ( {MIN_COLUMN_WIDTH}px)
</p>
</div>
{/* 간격 */}
<div className="space-y-1">
<Label htmlFor="gap" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.gap}px</span>
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateGridSetting("gap", value)}
className="w-full"
/>
</div>
{/* 여백 */}
<div className="space-y-1">
<Label htmlFor="padding" className="text-xs font-medium">
: <span className="text-primary">{gridSettings.padding}px</span>
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateGridSetting("padding", value)}
className="w-full"
/>
</div>
</div>
</div>
@ -415,90 +455,47 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
)}
{/* Z-Index */}
<div className="space-y-1">
<Label className="text-xs">Z-Index</Label>
<Input
type="number"
step="1"
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
{/* 크기 (너비/높이) */}
{/* Grid Columns + Z-Index (같은 행) */}
<div className="grid grid-cols-2 gap-2">
{(selectedComponent as any).gridColumns !== undefined && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={1}
max={gridSettings?.columns || 12}
step="1"
value={(selectedComponent as any).gridColumns || 1}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
const maxColumns = gridSettings?.columns || 12;
if (!isNaN(value) && value >= 1 && value <= maxColumns) {
handleUpdate("gridColumns", value);
// width를 퍼센트로 계산하여 업데이트
const widthPercent = (value / maxColumns) * 100;
handleUpdate("style.width", `${widthPercent}%`);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
/{gridSettings?.columns || 12}
</span>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Label className="text-xs">Z-Index</Label>
<Input
type="number"
step="1"
value={localSize.width}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트
const value = e.target.value === "" ? "" : parseInt(e.target.value);
setLocalSize((prev) => ({ ...prev, width: value as number }));
}}
onBlur={(e) => {
// 포커스 아웃 시 실제 컴포넌트 업데이트
const rawValue = e.target.value;
const parsedValue = parseInt(rawValue);
const newWidth = Math.max(10, parsedValue || 10);
// 로컬 상태도 최종값으로 업데이트
setLocalSize((prev) => ({ ...prev, width: newWidth }));
// size.width 경로로 업데이트 (격자 스냅 적용됨)
handleUpdate("size.width", newWidth);
}}
onKeyDown={(e) => {
// Enter 키로도 즉시 적용
if (e.key === "Enter") {
const newWidth = Math.max(10, parseInt((e.target as HTMLInputElement).value) || 10);
setLocalSize((prev) => ({ ...prev, width: newWidth }));
handleUpdate("size.width", newWidth);
(e.target as HTMLInputElement).blur();
}
}}
value={currentPosition.z || 1}
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
step="1"
value={localSize.height}
onChange={(e) => {
// 입력 중에는 로컬 상태만 업데이트
const value = e.target.value === "" ? "" : parseInt(e.target.value);
setLocalSize((prev) => ({ ...prev, height: value as number }));
}}
onBlur={(e) => {
// 포커스 아웃 시 실제 컴포넌트 업데이트
const rawValue = e.target.value;
const parsedValue = parseInt(rawValue);
const newHeight = Math.max(10, parsedValue || 10);
// 로컬 상태도 최종값으로 업데이트
setLocalSize((prev) => ({ ...prev, height: newHeight }));
// size.height 경로로 업데이트 (격자 스냅 적용됨)
handleUpdate("size.height", newHeight);
}}
onKeyDown={(e) => {
// Enter 키로도 즉시 적용
if (e.key === "Enter") {
const newHeight = Math.max(10, parseInt((e.target as HTMLInputElement).value) || 10);
setLocalSize((prev) => ({ ...prev, height: newHeight }));
handleUpdate("size.height", newHeight);
(e.target as HTMLInputElement).blur();
}
}}
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
</div>

View File

@ -83,7 +83,7 @@ export const dataApi = {
*/
createRecord: async (tableName: string, data: Record<string, any>): Promise<any> => {
const response = await apiClient.post(`/data/${tableName}`, data);
return response.data?.data || response.data;
return response.data; // success, data, message 포함된 전체 응답 반환
},
/**
@ -94,15 +94,23 @@ export const dataApi = {
*/
updateRecord: async (tableName: string, id: string | number, data: Record<string, any>): Promise<any> => {
const response = await apiClient.put(`/data/${tableName}/${id}`, data);
return response.data?.data || response.data;
return response.data; // success, data, message 포함된 전체 응답 반환
},
/**
*
* @param tableName
* @param id ID
* @param id ID
*/
deleteRecord: async (tableName: string, id: string | number): Promise<void> => {
await apiClient.delete(`/data/${tableName}/${id}`);
deleteRecord: async (tableName: string, id: string | number | Record<string, any>): Promise<any> => {
// 복합키 객체인 경우 POST로 전달
if (typeof id === 'object' && !Array.isArray(id)) {
const response = await apiClient.post(`/data/${tableName}/delete`, id);
return response.data;
}
// 단일 ID인 경우 기존 방식
const response = await apiClient.delete(`/data/${tableName}/${id}`);
return response.data; // success, message 포함된 전체 응답 반환
},
};

View File

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

View File

@ -771,7 +771,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", file.realFileName);
// objid가 없거나 유효하지 않으면 로드 중단
if (!file.objid || file.objid === "0" || file.objid === "") {
console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file);
setRepresentativeImageUrl(null);
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", {
objid: file.objid,
fileName: file.realFileName,
});
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
const response = await apiClient.get(`/files/download/${file.objid}`, {
@ -792,8 +802,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setRepresentativeImageUrl(url);
console.log("✅ 대표 이미지 로드 성공:", url);
} catch (error) {
console.error("❌ 대표 이미지 로드 실패:", error);
} catch (error: any) {
console.error("❌ 대표 이미지 로드 실패:", {
file: file.realFileName,
objid: file.objid,
error: error?.response?.status || error?.message,
});
setRepresentativeImageUrl(null);
}
},

View File

@ -9,7 +9,8 @@ import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, ArrowRight } from "lucide-react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "./types";
import { TableInfo, ColumnInfo } from "@/types/screen";
@ -74,6 +75,61 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenTableName]);
// 좌측 패널 테이블 컬럼 로드 완료 시 PK 자동 추가
useEffect(() => {
const leftTableName = config.leftPanel?.tableName || screenTableName;
if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showAdd) {
const currentAddModalColumns = config.leftPanel?.addModalColumns || [];
const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns);
// PK가 추가되었으면 업데이트
if (updatedColumns.length !== currentAddModalColumns.length) {
console.log(`🔄 좌측 패널: PK 컬럼 자동 추가 (${leftTableName})`);
updateLeftPanel({ addModalColumns: updatedColumns });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showAdd]);
// 좌측 패널 하위 항목 추가 모달 PK 자동 추가
useEffect(() => {
const leftTableName = config.leftPanel?.tableName || screenTableName;
if (leftTableName && loadedTableColumns[leftTableName] && config.leftPanel?.showItemAddButton) {
const currentAddModalColumns = config.leftPanel?.itemAddConfig?.addModalColumns || [];
const updatedColumns = ensurePrimaryKeysInAddModal(leftTableName, currentAddModalColumns);
// PK가 추가되었으면 업데이트
if (updatedColumns.length !== currentAddModalColumns.length) {
console.log(`🔄 좌측 패널 하위 항목 추가: PK 컬럼 자동 추가 (${leftTableName})`);
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
addModalColumns: updatedColumns,
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
}
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.leftPanel?.tableName, screenTableName, loadedTableColumns, config.leftPanel?.showItemAddButton]);
// 우측 패널 테이블 컬럼 로드 완료 시 PK 자동 추가
useEffect(() => {
const rightTableName = config.rightPanel?.tableName;
if (rightTableName && loadedTableColumns[rightTableName] && config.rightPanel?.showAdd) {
const currentAddModalColumns = config.rightPanel?.addModalColumns || [];
const updatedColumns = ensurePrimaryKeysInAddModal(rightTableName, currentAddModalColumns);
// PK가 추가되었으면 업데이트
if (updatedColumns.length !== currentAddModalColumns.length) {
console.log(`🔄 우측 패널: PK 컬럼 자동 추가 (${rightTableName})`);
updateRightPanel({ addModalColumns: updatedColumns });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.rightPanel?.tableName, loadedTableColumns, config.rightPanel?.showAdd]);
// 테이블 컬럼 로드 함수
const loadTableColumns = async (tableName: string) => {
if (loadedTableColumns[tableName] || loadingColumns[tableName]) {
@ -98,6 +154,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
isPrimaryKey: col.isPrimaryKey || false, // PK 여부 추가
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
}));
@ -139,6 +196,44 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onChange(newConfig);
};
// PK 컬럼을 추가 모달에 자동으로 포함시키는 함수
const ensurePrimaryKeysInAddModal = (
tableName: string,
existingColumns: Array<{ name: string; label: string; required?: boolean }> = []
) => {
const tableColumns = loadedTableColumns[tableName];
if (!tableColumns) {
console.warn(`⚠️ 테이블 ${tableName}의 컬럼 정보가 로드되지 않음`);
return existingColumns;
}
// PK 컬럼 찾기
const pkColumns = tableColumns.filter((col) => col.isPrimaryKey);
console.log(`🔑 테이블 ${tableName}의 PK 컬럼:`, pkColumns.map(c => c.columnName));
// 자동으로 처리되는 컬럼 (백엔드에서 자동 추가)
const autoHandledColumns = ['company_code', 'company_name'];
// 기존 컬럼 이름 목록
const existingColumnNames = existingColumns.map((col) => col.name);
// PK 컬럼을 맨 앞에 추가 (이미 있거나 자동 처리되는 컬럼은 제외)
const pkColumnsToAdd = pkColumns
.filter((col) => !existingColumnNames.includes(col.columnName))
.filter((col) => !autoHandledColumns.includes(col.columnName)) // 자동 처리 컬럼 제외
.map((col) => ({
name: col.columnName,
label: col.columnLabel || col.columnName,
required: true, // PK는 항상 필수
}));
if (pkColumnsToAdd.length > 0) {
console.log(`✅ PK 컬럼 ${pkColumnsToAdd.length}개 자동 추가:`, pkColumnsToAdd.map(c => c.name));
}
return [...pkColumnsToAdd, ...existingColumns];
};
const updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
const newConfig = {
...config,
@ -190,7 +285,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
console.log(" - availableRightTables:", availableRightTables.length, "개");
return (
<div className="space-y-6">
<div className="space-y-4">
{/* 관계 타입 선택 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold"> </h3>
@ -230,9 +325,14 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</Select>
</div>
{/* 좌측 패널 설정 (마스터) */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> ()</h3>
{/* 좌측 패널 설정 (Accordion) */}
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
<AccordionItem value="left-panel" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
()
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label> </Label>
@ -268,11 +368,464 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })}
/>
</div>
</div>
{/* 우측 패널 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조인"})</h3>
<div className="flex items-center justify-between">
<Label> + </Label>
<Switch
checked={config.leftPanel?.showItemAddButton ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showItemAddButton: checked })}
/>
</div>
{/* 항목별 + 버튼 설정 (하위 항목 추가) */}
{config.leftPanel?.showItemAddButton && (
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<Label className="text-sm font-semibold"> </Label>
<p className="text-xs text-gray-600">
+ (: 부서 )
</p>
{/* 현재 항목의 값을 가져올 컬럼 (sourceColumn) */}
<div>
<Label className="text-xs"> ID </Label>
<p className="mb-2 text-[10px] text-gray-500">
(: dept_code)
</p>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.leftPanel?.itemAddConfig?.sourceColumn || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
sourceColumn: value,
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
}
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.leftPanel?.itemAddConfig?.sourceColumn === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 상위 항목 ID를 저장할 컬럼 (parentColumn) */}
<div>
<Label className="text-xs"> </Label>
<p className="mb-2 text-[10px] text-gray-500">
ID를 (: parent_dept_code)
</p>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.leftPanel?.itemAddConfig?.parentColumn || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
parentColumn: value,
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
}
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.leftPanel?.itemAddConfig?.parentColumn === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 하위 항목 추가 모달 컬럼 설정 */}
<div className="space-y-2 rounded border border-blue-300 bg-white p-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = config.leftPanel?.itemAddConfig?.addModalColumns || [];
const newColumns = [
...currentColumns,
{ name: "", label: "", required: false },
];
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
addModalColumns: newColumns,
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
}
});
}}
className="h-6 text-[10px]"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-gray-600">
</p>
<div className="space-y-2">
{(config.leftPanel?.itemAddConfig?.addModalColumns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-2 text-center">
<p className="text-[10px] text-gray-500"> </p>
</div>
) : (
(config.leftPanel?.itemAddConfig?.addModalColumns || []).map((col, index) => {
const column = leftTableColumns.find(c => c.columnName === col.name);
const isPK = column?.isPrimaryKey || false;
return (
<div
key={index}
className={cn(
"flex items-center gap-2 rounded-md border p-2",
isPK ? "border-yellow-300 bg-yellow-50" : "bg-white"
)}
>
{isPK && (
<span className="rounded bg-yellow-200 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-700">
PK
</span>
)}
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPK}
className="h-7 w-full justify-between text-[10px]"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.leftPanel?.itemAddConfig?.addModalColumns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
addModalColumns: newColumns,
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
}
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-1">
<label className="flex cursor-pointer items-center gap-1 text-[10px] text-gray-600">
<input
type="checkbox"
checked={col.required ?? false}
disabled={isPK}
onChange={(e) => {
const newColumns = [...(config.leftPanel?.itemAddConfig?.addModalColumns || [])];
newColumns[index] = {
...newColumns[index],
required: e.target.checked,
};
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
addModalColumns: newColumns,
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
}
});
}}
className="h-3 w-3"
/>
</label>
</div>
<Button
size="sm"
variant="ghost"
disabled={isPK}
onClick={() => {
const newColumns = (config.leftPanel?.itemAddConfig?.addModalColumns || []).filter(
(_, i) => i !== index
);
updateLeftPanel({
itemAddConfig: {
...config.leftPanel?.itemAddConfig,
addModalColumns: newColumns,
parentColumn: config.leftPanel?.itemAddConfig?.parentColumn || "",
sourceColumn: config.leftPanel?.itemAddConfig?.sourceColumn || "",
}
});
}}
className="h-7 w-7 p-0"
title={isPK ? "PK 컬럼은 삭제할 수 없습니다" : ""}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
</div>
</div>
)}
{/* 좌측 패널 추가 모달 컬럼 설정 */}
{config.leftPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = config.leftPanel?.addModalColumns || [];
const newColumns = [
...currentColumns,
{ name: "", label: "", required: false },
];
updateLeftPanel({ addModalColumns: newColumns });
}}
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-xs text-gray-600">
</p>
<div className="space-y-2">
{(config.leftPanel?.addModalColumns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
</div>
) : (
(config.leftPanel?.addModalColumns || []).map((col, index) => {
// 현재 컬럼이 PK인지 확인
const column = leftTableColumns.find(c => c.columnName === col.name);
const isPK = column?.isPrimaryKey || false;
return (
<div
key={index}
className={cn(
"flex items-center gap-2 rounded-md border p-2",
isPK ? "bg-yellow-50 border-yellow-300" : "bg-white"
)}
>
{isPK && (
<span className="text-[10px] font-semibold text-yellow-700 px-1.5 py-0.5 bg-yellow-200 rounded">
PK
</span>
)}
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPK}
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.leftPanel?.addModalColumns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateLeftPanel({ addModalColumns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-1">
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={col.required ?? false}
disabled={isPK}
onChange={(e) => {
const newColumns = [...(config.leftPanel?.addModalColumns || [])];
newColumns[index] = {
...newColumns[index],
required: e.target.checked,
};
updateLeftPanel({ addModalColumns: newColumns });
}}
className="h-3 w-3"
/>
</label>
</div>
<Button
size="sm"
variant="ghost"
disabled={isPK}
onClick={() => {
const newColumns = (config.leftPanel?.addModalColumns || []).filter(
(_, i) => i !== index
);
updateLeftPanel({ addModalColumns: newColumns });
}}
className="h-8 w-8 p-0"
title={isPK ? "PK 컬럼은 삭제할 수 없습니다" : ""}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 우측 패널 설정 (Accordion) */}
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
<AccordionItem value="right-panel" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
({relationshipType === "detail" ? "상세" : "조인"})
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label> </Label>
@ -467,11 +1020,370 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })}
/>
</div>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
{/* 우측 패널 표시 컬럼 설정 */}
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = config.rightPanel?.columns || [];
const newColumns = [
...currentColumns,
{ name: "", label: "", width: 100 },
];
updateRightPanel({ columns: newColumns });
}}
className="h-7 text-xs"
disabled={!config.rightPanel?.tableName}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-xs text-gray-600">
. .
</p>
{/* 선택된 컬럼 목록 */}
<div className="space-y-2">
{(config.rightPanel?.columns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
<p className="mt-1 text-[10px] text-gray-400">
</p>
</div>
) : (
(config.rightPanel?.columns || []).map((col, index) => (
<div
key={index}
className="flex items-center gap-2 rounded-md border bg-white p-2"
>
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateRightPanel({ columns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (config.rightPanel?.columns || []).filter(
(_, i) => i !== index
);
updateRightPanel({ columns: newColumns });
}}
className="h-8 w-8 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</div>
{/* 우측 패널 추가 모달 컬럼 설정 */}
{config.rightPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = config.rightPanel?.addModalColumns || [];
const newColumns = [
...currentColumns,
{ name: "", label: "", required: false },
];
updateRightPanel({ addModalColumns: newColumns });
}}
className="h-7 text-xs"
disabled={!config.rightPanel?.tableName}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-xs text-gray-600">
</p>
<div className="space-y-2">
{(config.rightPanel?.addModalColumns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
</div>
) : (
(config.rightPanel?.addModalColumns || []).map((col, index) => {
// 현재 컬럼이 PK인지 확인
const column = rightTableColumns.find(c => c.columnName === col.name);
const isPK = column?.isPrimaryKey || false;
return (
<div
key={index}
className={cn(
"flex items-center gap-2 rounded-md border p-2",
isPK ? "bg-yellow-50 border-yellow-300" : "bg-white"
)}
>
{isPK && (
<span className="text-[10px] font-semibold text-yellow-700 px-1.5 py-0.5 bg-yellow-200 rounded">
PK
</span>
)}
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPK}
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns
.filter((column) => !['company_code', 'company_name'].includes(column.columnName))
.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.rightPanel?.addModalColumns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateRightPanel({ addModalColumns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-1">
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={col.required ?? false}
disabled={isPK}
onChange={(e) => {
const newColumns = [...(config.rightPanel?.addModalColumns || [])];
newColumns[index] = {
...newColumns[index],
required: e.target.checked,
};
updateRightPanel({ addModalColumns: newColumns });
}}
className="h-3 w-3"
/>
</label>
</div>
<Button
size="sm"
variant="ghost"
disabled={isPK}
onClick={() => {
const newColumns = (config.rightPanel?.addModalColumns || []).filter(
(_, i) => i !== index
);
updateRightPanel({ addModalColumns: newColumns });
}}
className="h-8 w-8 p-0"
title={isPK ? "PK 컬럼은 삭제할 수 없습니다" : ""}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
{/* 중계 테이블 설정 */}
<div className="space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-3 mt-3">
<Label className="text-sm font-semibold"> (N:M )</Label>
<p className="text-xs text-gray-600">
</p>
<div>
<Label className="text-xs text-gray-700"> </Label>
<Input
value={config.rightPanel?.addConfig?.targetTable || ""}
onChange={(e) => {
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
targetTable: e.target.value,
},
});
}}
placeholder="예: user_dept"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-gray-500">
</p>
</div>
<div>
<Label className="text-xs text-gray-700"> </Label>
<Input
value={config.rightPanel?.addConfig?.leftPanelColumn || ""}
onChange={(e) => {
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
leftPanelColumn: e.target.value,
},
});
}}
placeholder="예: dept_code"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-gray-500">
</p>
</div>
<div>
<Label className="text-xs text-gray-700"> </Label>
<Input
value={config.rightPanel?.addConfig?.targetColumn || ""}
onChange={(e) => {
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
targetColumn: e.target.value,
},
});
}}
placeholder="예: dept_code"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-[10px] text-gray-500">
</p>
</div>
<div>
<Label className="text-xs text-gray-700"> (JSON)</Label>
<textarea
value={JSON.stringify(config.rightPanel?.addConfig?.autoFillColumns || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
const addConfig = config.rightPanel?.addConfig || {};
updateRightPanel({
addConfig: {
...addConfig,
autoFillColumns: parsed,
},
});
} catch (err) {
// JSON 파싱 오류는 무시 (입력 중)
}
}}
placeholder='{ "is_primary": false }'
className="mt-1 h-20 w-full rounded-md border border-input bg-white px-3 py-2 text-xs font-mono"
/>
<p className="mt-1 text-[10px] text-gray-500">
(: is_primary: false)
</p>
</div>
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 레이아웃 설정 (Accordion) */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="layout" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label>
@ -499,7 +1411,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View File

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

View File

@ -1,77 +1,205 @@
import { Position, Size } from "@/types/screen";
import { GridSettings } from "@/types/screen-management";
// 🎯 10px 고정 격자 시스템
const GRID_SIZE = 10; // 고정값
export interface GridInfo {
gridSize: number; // 항상 10px
columnWidth: number;
totalWidth: number;
totalHeight: number;
}
/**
* ()
*
*/
export function calculateGridInfo(
containerWidth: number,
containerHeight: number,
_gridSettings?: GridSettings, // 호환성 유지용 (사용 안 함)
gridSettings: GridSettings,
): GridInfo {
const { gap, padding } = gridSettings;
let { columns } = gridSettings;
// 🔥 최소 컬럼 너비를 보장하기 위한 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30; // 최소 컬럼 너비 30px
const availableWidth = containerWidth - padding * 2;
const maxPossibleColumns = Math.floor((availableWidth + gap) / (MIN_COLUMN_WIDTH + gap));
// 설정된 컬럼 수가 너무 많으면 자동으로 제한
if (columns > maxPossibleColumns) {
console.warn(
`⚠️ 격자 컬럼 수가 너무 많습니다. ${columns}개 → ${maxPossibleColumns}개로 자동 조정됨 (최소 컬럼 너비: ${MIN_COLUMN_WIDTH}px)`,
);
columns = Math.max(1, maxPossibleColumns);
}
// 격자 간격을 고려한 컬럼 너비 계산
const totalGaps = (columns - 1) * gap;
const columnWidth = (availableWidth - totalGaps) / columns;
return {
gridSize: GRID_SIZE,
columnWidth: Math.max(columnWidth, MIN_COLUMN_WIDTH),
totalWidth: containerWidth,
totalHeight: containerHeight,
};
}
/**
* 10px
*
*/
export function snapToGrid(position: Position, _gridInfo: GridInfo, gridSettings: GridSettings): Position {
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
if (!gridSettings.snapToGrid) {
return position;
}
const { columnWidth } = gridInfo;
const { gap, padding } = gridSettings;
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
const cellWidth = columnWidth + gap;
const cellHeight = 10; // 행 높이 10px 단위로 고정
// 패딩을 제외한 상대 위치
const relativeX = position.x - padding;
const relativeY = position.y - padding;
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
const gridX = Math.round(relativeX / cellWidth);
const gridY = Math.round(relativeY / cellHeight);
// 실제 픽셀 위치로 변환
const snappedX = Math.max(padding, padding + gridX * cellWidth);
const snappedY = Math.max(padding, padding + gridY * cellHeight);
return {
x: Math.round(position.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(position.y / GRID_SIZE) * GRID_SIZE,
x: snappedX,
y: snappedY,
z: position.z,
};
}
/**
* 10px
*
*/
export function snapSizeToGrid(size: Size, _gridInfo: GridInfo, gridSettings: GridSettings): Size {
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
if (!gridSettings.snapToGrid) {
return size;
}
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 격자 단위로 너비 계산
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 10px 단위로 스냅
const rowHeight = 10;
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(GRID_SIZE, Math.round(size.width / GRID_SIZE) * GRID_SIZE),
height: Math.max(GRID_SIZE, Math.round(size.height / GRID_SIZE) * GRID_SIZE),
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,
};
}
/**
* (10px )
*
*/
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return columns * columnWidth + (columns - 1) * gap;
}
/**
* gridColumns
*/
export function updateSizeFromGridColumns(
component: { gridColumns?: number; size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (!component.gridColumns || component.gridColumns < 1) {
return component.size;
}
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
return {
width: newWidth,
height: component.size.height, // 높이는 유지
};
}
/**
* gridColumns를
*/
export function adjustGridColumnsFromSize(
component: { size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): number {
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
}
/**
*
*/
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
}
/**
*
*/
export function generateGridLines(
containerWidth: number,
containerHeight: number,
_gridSettings?: GridSettings,
gridSettings: GridSettings,
): {
verticalLines: number[];
horizontalLines: number[];
} {
const { columns, gap, padding } = gridSettings;
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
const { columnWidth } = gridInfo;
// 격자 셀 크기 (스냅 로직과 동일하게)
const cellWidth = columnWidth + gap;
const cellHeight = 10; // 행 높이 10px 단위로 고정
// 세로 격자선
const verticalLines: number[] = [];
for (let x = 0; x <= containerWidth; x += GRID_SIZE) {
verticalLines.push(x);
for (let i = 0; i <= columns; i++) {
const x = padding + i * cellWidth;
if (x <= containerWidth) {
verticalLines.push(x);
}
}
// 가로 격자선
const horizontalLines: number[] = [];
for (let y = 0; y <= containerHeight; y += GRID_SIZE) {
for (let y = padding; y < containerHeight; y += cellHeight) {
horizontalLines.push(y);
}
@ -114,21 +242,46 @@ export function alignGroupChildrenToGrid(
): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
return children.map((child) => {
const padding = 16;
// 10px 단위로 스냅
const snappedX = Math.max(padding, Math.round((child.position.x - padding) / GRID_SIZE) * GRID_SIZE + padding);
const snappedY = Math.max(padding, Math.round((child.position.y - padding) / GRID_SIZE) * GRID_SIZE + padding);
const snappedWidth = Math.max(GRID_SIZE, Math.round(child.size.width / GRID_SIZE) * GRID_SIZE);
const snappedHeight = Math.max(GRID_SIZE, Math.round(child.size.height / GRID_SIZE) * GRID_SIZE);
console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
return {
return children.map((child, index) => {
console.log(`📐 자식 ${index + 1} 처리 중:`, {
childId: child.id,
originalPosition: child.position,
originalSize: child.size,
});
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// Y 좌표는 10px 단위로 스냅
const rowHeight = 10;
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(10, Math.round(child.size.height / rowHeight) * rowHeight);
const snappedChild = {
...child,
position: {
x: snappedX,
y: snappedY,
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: child.position.z || 1,
},
size: {
@ -136,6 +289,26 @@ export function alignGroupChildrenToGrid(
height: snappedHeight,
},
};
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
childId: child.id,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
widthInColumns,
originalX: child.position.x,
snappedX: snappedChild.position.x,
padding,
},
snappedPosition: snappedChild.position,
snappedSize: snappedChild.size,
deltaX: snappedChild.position.x - child.position.x,
deltaY: snappedChild.position.y - child.position.y,
});
return snappedChild;
});
}
@ -144,13 +317,19 @@ export function alignGroupChildrenToGrid(
*/
export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>,
_gridInfo?: GridInfo,
_gridSettings?: GridSettings,
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: GRID_SIZE * 20, height: GRID_SIZE * 10 };
return { width: gridInfo.columnWidth * 2, height: 10 * 4 };
}
console.log("📏 calculateOptimalGroupSize 시작:", {
childrenCount: children.length,
children: children.map((c) => ({ pos: c.position, size: c.size })),
});
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
const bounds = children.reduce(
(acc, child) => ({
minX: Math.min(acc.minX, child.position.x),
@ -161,38 +340,61 @@ export function calculateOptimalGroupSize(
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY;
const padding = 16;
return {
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2,
height: contentHeight + padding * 2,
};
console.log("✅ 자연스러운 그룹 크기:", {
contentSize: { width: contentWidth, height: contentHeight },
withPadding: groupSize,
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
});
return groupSize;
}
/**
*
*/
export function normalizeGroupChildPositions(children: any[], _gridSettings?: GridSettings): any[] {
if (children.length === 0) return children;
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔄 normalizeGroupChildPositions 시작:", {
childrenCount: children.length,
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
});
// 모든 자식의 최소 위치 찾기
const minX = Math.min(...children.map((child) => child.position.x));
const minY = Math.min(...children.map((child) => child.position.y));
const padding = 16;
return children.map((child) => ({
console.log("📍 최소 위치:", { minX, minY });
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
const padding = 16;
const startX = padding;
const startY = padding;
const normalizedChildren = children.map((child) => ({
...child,
position: {
x: child.position.x - minX + padding,
y: child.position.y - minY + padding,
x: child.position.x - minX + startX,
y: child.position.y - minY + startY,
z: child.position.z || 1,
},
}));
}
// 🗑️ 제거된 함수들 (더 이상 필요 없음)
// - calculateWidthFromColumns
// - updateSizeFromGridColumns
// - adjustGridColumnsFromSize
// - calculateColumnsFromWidth
console.log("✅ 정규화 완료:", {
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
});
return normalizedChildren;
}

View File

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