Compare commits
No commits in common. "832e80cd7f1e43f8cec44251a7f03836368b13d3" and "7cc325edd5576d74576155b736bfa47a2291d346" have entirely different histories.
832e80cd7f
...
7cc325edd5
|
|
@ -1,279 +0,0 @@
|
||||||
# inputType 사용 가이드
|
|
||||||
|
|
||||||
## 핵심 원칙
|
|
||||||
|
|
||||||
**컬럼 타입 판단 시 반드시 `inputType`을 사용해야 합니다. `webType`은 레거시이며 더 이상 사용하지 않습니다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 올바른 사용법
|
|
||||||
|
|
||||||
### ✅ inputType 사용 (권장)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 카테고리 타입 체크
|
|
||||||
if (columnMeta.inputType === "category") {
|
|
||||||
// 카테고리 처리 로직
|
|
||||||
}
|
|
||||||
|
|
||||||
// 코드 타입 체크
|
|
||||||
if (meta.inputType === "code") {
|
|
||||||
// 코드 처리 로직
|
|
||||||
}
|
|
||||||
|
|
||||||
// 필터링
|
|
||||||
const categoryColumns = Object.entries(columnMeta)
|
|
||||||
.filter(([_, meta]) => meta.inputType === "category")
|
|
||||||
.map(([columnName, _]) => columnName);
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ webType 사용 (금지)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 절대 사용 금지!
|
|
||||||
if (columnMeta.webType === "category") { ... }
|
|
||||||
|
|
||||||
// ❌ 이것도 금지!
|
|
||||||
const categoryColumns = columns.filter(col => col.webType === "category");
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API에서 inputType 가져오기
|
|
||||||
|
|
||||||
### Backend API
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 컬럼 입력 타입 정보 가져오기
|
|
||||||
const inputTypes = await tableTypeApi.getColumnInputTypes(tableName);
|
|
||||||
|
|
||||||
// inputType 맵 생성
|
|
||||||
const inputTypeMap: Record<string, string> = {};
|
|
||||||
inputTypes.forEach((col: any) => {
|
|
||||||
inputTypeMap[col.columnName] = col.inputType;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### columnMeta 구조
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ColumnMeta {
|
|
||||||
webType?: string; // 레거시, 사용 금지
|
|
||||||
codeCategory?: string;
|
|
||||||
inputType?: string; // ✅ 반드시 이것 사용!
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnMeta: Record<string, ColumnMeta> = {
|
|
||||||
material: {
|
|
||||||
webType: "category", // 무시
|
|
||||||
codeCategory: "",
|
|
||||||
inputType: "category", // ✅ 이것만 사용
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 캐시 사용 시 주의사항
|
|
||||||
|
|
||||||
### ❌ 잘못된 캐시 처리 (inputType 누락)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const cached = tableColumnCache.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
const meta: Record<string, ColumnMeta> = {};
|
|
||||||
|
|
||||||
cached.columns.forEach((col: any) => {
|
|
||||||
meta[col.columnName] = {
|
|
||||||
webType: col.webType,
|
|
||||||
codeCategory: col.codeCategory,
|
|
||||||
// ❌ inputType 누락!
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ 올바른 캐시 처리 (inputType 포함)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const cached = tableColumnCache.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
const meta: Record<string, ColumnMeta> = {};
|
|
||||||
|
|
||||||
// 캐시된 inputTypes 맵 생성
|
|
||||||
const inputTypeMap: Record<string, string> = {};
|
|
||||||
if (cached.inputTypes) {
|
|
||||||
cached.inputTypes.forEach((col: any) => {
|
|
||||||
inputTypeMap[col.columnName] = col.inputType;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cached.columns.forEach((col: any) => {
|
|
||||||
meta[col.columnName] = {
|
|
||||||
webType: col.webType,
|
|
||||||
codeCategory: col.codeCategory,
|
|
||||||
inputType: inputTypeMap[col.columnName], // ✅ inputType 포함!
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주요 inputType 종류
|
|
||||||
|
|
||||||
| inputType | 설명 | 사용 예시 |
|
|
||||||
| ---------- | ---------------- | ------------------ |
|
|
||||||
| `text` | 일반 텍스트 입력 | 이름, 설명 등 |
|
|
||||||
| `number` | 숫자 입력 | 금액, 수량 등 |
|
|
||||||
| `date` | 날짜 입력 | 생성일, 수정일 등 |
|
|
||||||
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
|
|
||||||
| `category` | 카테고리 선택 | 분류, 상태 등 |
|
|
||||||
| `code` | 공통 코드 선택 | 코드 마스터 데이터 |
|
|
||||||
| `boolean` | 예/아니오 | 활성화 여부 등 |
|
|
||||||
| `email` | 이메일 입력 | 이메일 주소 |
|
|
||||||
| `url` | URL 입력 | 웹사이트 주소 |
|
|
||||||
| `image` | 이미지 업로드 | 프로필 사진 등 |
|
|
||||||
| `file` | 파일 업로드 | 첨부파일 등 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 실제 적용 사례
|
|
||||||
|
|
||||||
### 1. TableListComponent - 카테고리 매핑 로드
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ inputType으로 카테고리 컬럼 필터링
|
|
||||||
const categoryColumns = Object.entries(columnMeta)
|
|
||||||
.filter(([_, meta]) => meta.inputType === "category")
|
|
||||||
.map(([columnName, _]) => columnName);
|
|
||||||
|
|
||||||
// 각 카테고리 컬럼의 값 목록 조회
|
|
||||||
for (const columnName of categoryColumns) {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/table-categories/${tableName}/${columnName}/values`
|
|
||||||
);
|
|
||||||
// 매핑 처리...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. InteractiveDataTable - 셀 값 렌더링
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ inputType으로 렌더링 분기
|
|
||||||
const inputType = columnMeta[column.columnName]?.inputType;
|
|
||||||
|
|
||||||
switch (inputType) {
|
|
||||||
case "category":
|
|
||||||
// 카테고리 배지 렌더링
|
|
||||||
return <Badge>{categoryLabel}</Badge>;
|
|
||||||
|
|
||||||
case "code":
|
|
||||||
// 코드명 표시
|
|
||||||
return codeName;
|
|
||||||
|
|
||||||
case "date":
|
|
||||||
// 날짜 포맷팅
|
|
||||||
return formatDate(value);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 검색 필터 생성
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ inputType에 따라 다른 검색 UI 제공
|
|
||||||
const renderSearchInput = (column: ColumnConfig) => {
|
|
||||||
const inputType = columnMeta[column.columnName]?.inputType;
|
|
||||||
|
|
||||||
switch (inputType) {
|
|
||||||
case "category":
|
|
||||||
return <CategorySelect column={column} />;
|
|
||||||
|
|
||||||
case "code":
|
|
||||||
return <CodeSelect column={column} />;
|
|
||||||
|
|
||||||
case "date":
|
|
||||||
return <DateRangePicker column={column} />;
|
|
||||||
|
|
||||||
case "number":
|
|
||||||
return <NumberRangeInput column={column} />;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return <TextInput column={column} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 마이그레이션 체크리스트
|
|
||||||
|
|
||||||
기존 코드에서 `webType`을 `inputType`으로 전환할 때:
|
|
||||||
|
|
||||||
- [ ] `webType` 참조를 모두 `inputType`으로 변경
|
|
||||||
- [ ] API 호출 시 `getColumnInputTypes()` 포함 확인
|
|
||||||
- [ ] 캐시 사용 시 `cached.inputTypes` 매핑 확인
|
|
||||||
- [ ] 타입 정의에서 `inputType` 필드 포함
|
|
||||||
- [ ] 조건문에서 `inputType` 체크로 변경
|
|
||||||
- [ ] 테스트 실행하여 정상 동작 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 디버깅 팁
|
|
||||||
|
|
||||||
### inputType이 undefined인 경우
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 디버깅 로그 추가
|
|
||||||
console.log("columnMeta:", columnMeta);
|
|
||||||
console.log("inputType:", columnMeta[columnName]?.inputType);
|
|
||||||
|
|
||||||
// 체크 포인트:
|
|
||||||
// 1. getColumnInputTypes() 호출 확인
|
|
||||||
// 2. inputTypeMap 생성 확인
|
|
||||||
// 3. meta 객체에 inputType 할당 확인
|
|
||||||
// 4. 캐시 사용 시 cached.inputTypes 확인
|
|
||||||
```
|
|
||||||
|
|
||||||
### webType만 있고 inputType이 없는 경우
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 잘못된 데이터 구조
|
|
||||||
{
|
|
||||||
material: {
|
|
||||||
webType: "category",
|
|
||||||
codeCategory: "",
|
|
||||||
// inputType 누락!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 올바른 데이터 구조
|
|
||||||
{
|
|
||||||
material: {
|
|
||||||
webType: "category", // 레거시, 무시됨
|
|
||||||
codeCategory: "",
|
|
||||||
inputType: "category" // ✅ 필수!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 참고 자료
|
|
||||||
|
|
||||||
- **컴포넌트**: `/frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
|
||||||
- **API 클라이언트**: `/frontend/lib/api/tableType.ts`
|
|
||||||
- **타입 정의**: `/frontend/types/table.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 요약
|
|
||||||
|
|
||||||
1. **항상 `inputType` 사용**, `webType` 사용 금지
|
|
||||||
2. **API에서 `getColumnInputTypes()` 호출** 필수
|
|
||||||
3. **캐시 사용 시 `inputTypes` 포함** 확인
|
|
||||||
4. **디버깅 시 `inputType` 값 확인**
|
|
||||||
5. **기존 코드 마이그레이션** 시 체크리스트 활용
|
|
||||||
|
|
@ -67,12 +67,6 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
|
||||||
description: "단일 선택",
|
description: "단일 선택",
|
||||||
category: "selection",
|
category: "selection",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "image",
|
|
||||||
label: "이미지",
|
|
||||||
description: "이미지 표시",
|
|
||||||
category: "basic",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 입력 타입 검증 함수
|
// 입력 타입 검증 함수
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# 마이그레이션 043: 이미지 컬럼을 TEXT 타입으로 변경
|
|
||||||
|
|
||||||
## 목적
|
|
||||||
Base64 인코딩된 이미지 데이터를 저장하기 위해 VARCHAR(500) 컬럼을 TEXT 타입으로 변경합니다.
|
|
||||||
|
|
||||||
## 영향받는 테이블
|
|
||||||
- `item_info.image`
|
|
||||||
- `user_info.image` (존재하는 경우)
|
|
||||||
- 기타 `image`, `img`, `picture`, `photo` 이름을 가진 VARCHAR 컬럼들
|
|
||||||
|
|
||||||
## 실행 방법
|
|
||||||
|
|
||||||
### Docker 환경
|
|
||||||
```bash
|
|
||||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/043_change_image_columns_to_text.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 로컬 PostgreSQL
|
|
||||||
```bash
|
|
||||||
psql -U postgres -d ilshin -f db/migrations/043_change_image_columns_to_text.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## 확인 방법
|
|
||||||
```sql
|
|
||||||
-- 변경된 컬럼 확인
|
|
||||||
SELECT
|
|
||||||
table_name,
|
|
||||||
column_name,
|
|
||||||
data_type,
|
|
||||||
character_maximum_length
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND column_name ILIKE '%image%'
|
|
||||||
ORDER BY table_name, column_name;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 롤백 방법
|
|
||||||
```sql
|
|
||||||
-- 필요시 원래대로 되돌리기 (데이터 손실 주의!)
|
|
||||||
ALTER TABLE item_info ALTER COLUMN image TYPE VARCHAR(500);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 주의사항
|
|
||||||
- TEXT 타입은 길이 제한이 없으므로 매우 큰 이미지도 저장 가능합니다.
|
|
||||||
- Base64 인코딩은 원본 파일 크기의 약 1.33배가 됩니다.
|
|
||||||
- 5MB 이미지 → Base64: 약 6.7MB → 문자열: 약 6.7백만 자
|
|
||||||
- 성능상 매우 큰 이미지는 파일 서버에 저장하고 URL만 저장하는 것이 좋습니다.
|
|
||||||
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/**
|
|
||||||
* 브라우저 콘솔에서 실행하는 마이그레이션 스크립트
|
|
||||||
*
|
|
||||||
* 사용 방법:
|
|
||||||
* 1. 브라우저에서 ERP 시스템에 로그인
|
|
||||||
* 2. F12 눌러서 개발자 도구 열기
|
|
||||||
* 3. Console 탭 선택
|
|
||||||
* 4. 아래 코드 전체를 복사해서 붙여넣고 Enter
|
|
||||||
*/
|
|
||||||
|
|
||||||
(async function runMigration() {
|
|
||||||
console.log("🚀 마이그레이션 043 시작: 이미지 컬럼을 TEXT로 변경");
|
|
||||||
|
|
||||||
const sql = `
|
|
||||||
-- item_info 테이블의 image 컬럼 변경
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'item_info'
|
|
||||||
AND column_name = 'image'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE item_info
|
|
||||||
ALTER COLUMN image TYPE TEXT;
|
|
||||||
|
|
||||||
RAISE NOTICE 'item_info.image 컬럼을 TEXT 타입으로 변경했습니다.';
|
|
||||||
ELSE
|
|
||||||
RAISE NOTICE 'item_info.image 컬럼이 존재하지 않습니다.';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 모든 테이블에서 image 관련 VARCHAR 컬럼을 TEXT로 변경
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
rec RECORD;
|
|
||||||
BEGIN
|
|
||||||
FOR rec IN
|
|
||||||
SELECT
|
|
||||||
table_name,
|
|
||||||
column_name,
|
|
||||||
character_maximum_length
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND data_type IN ('character varying', 'varchar')
|
|
||||||
AND (
|
|
||||||
column_name ILIKE '%image%' OR
|
|
||||||
column_name ILIKE '%img%' OR
|
|
||||||
column_name ILIKE '%picture%' OR
|
|
||||||
column_name ILIKE '%photo%'
|
|
||||||
)
|
|
||||||
AND character_maximum_length IS NOT NULL
|
|
||||||
AND character_maximum_length < 10000
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('ALTER TABLE %I ALTER COLUMN %I TYPE TEXT', rec.table_name, rec.column_name);
|
|
||||||
RAISE NOTICE '%.% 컬럼을 TEXT 타입으로 변경했습니다. (이전: VARCHAR(%))',
|
|
||||||
rec.table_name, rec.column_name, rec.character_maximum_length;
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/dashboards/execute-dml', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query: sql }),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log("✅ 마이그레이션 성공!", result);
|
|
||||||
console.log("📊 이제 이미지를 저장할 수 있습니다!");
|
|
||||||
} else {
|
|
||||||
console.error("❌ 마이그레이션 실패:", result);
|
|
||||||
console.log("💡 수동으로 실행해야 합니다. RUN_043_MIGRATION.md 참고");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 마이그레이션 오류:", error);
|
|
||||||
console.log("💡 수동으로 실행해야 합니다. RUN_043_MIGRATION.md 참고");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -321,20 +321,20 @@ export function CreateTableModal({
|
||||||
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>
|
<DialogDescription>
|
||||||
{isDuplicateMode
|
{isDuplicateMode
|
||||||
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
||||||
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
||||||
}
|
}
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 테이블 기본 정보 */}
|
{/* 테이블 기본 정보 */}
|
||||||
|
|
@ -452,7 +452,7 @@ export function CreateTableModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -482,8 +482,8 @@ export function CreateTableModal({
|
||||||
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
ResizableDialog,
|
|
||||||
ResizableDialogContent,
|
|
||||||
ResizableDialogHeader,
|
|
||||||
ResizableDialogTitle,
|
|
||||||
ResizableDialogFooter
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -148,14 +142,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
DDL 실행 로그 및 통계
|
DDL 실행 로그 및 통계
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="logs" className="w-full">
|
<Tabs defaultValue="logs" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
|
@ -407,7 +401,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
ResizableDialog,
|
|
||||||
ResizableDialogContent,
|
|
||||||
ResizableDialogHeader,
|
|
||||||
ResizableDialogTitle,
|
|
||||||
ResizableDialogFooter
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -126,14 +120,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<History className="h-5 w-5" />
|
<History className="h-5 w-5" />
|
||||||
{tableName} - 변경 이력
|
{tableName} - 변경 이력
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 필터 영역 */}
|
{/* 필터 영역 */}
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
|
@ -261,7 +255,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
|
||||||
ResizableDialogTitle,
|
|
||||||
ResizableDialogDescription,
|
DialogHeader,
|
||||||
ResizableDialogFooter,
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/resizable-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -119,10 +119,8 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<ResizableDialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ResizableDialogTitle>대시보드 저장 완료</ResizableDialogTitle>
|
<ResizableDialogTitle>대시보드 저장 완료</ResizableDialogTitle>
|
||||||
<ResizableDialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</ResizableDialogDescription>
|
<ResizableDialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</ResizableDialogDescription>
|
||||||
</div>
|
|
||||||
</ResizableDialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
|
||||||
ResizableDialogTitle,
|
|
||||||
ResizableDialogDescription,
|
DialogHeader,
|
||||||
ResizableDialogFooter,
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/resizable-dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -67,10 +67,8 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
<ResizableDialogHeader>
|
<ResizableDialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ResizableDialogTitle>새 야드 생성</ResizableDialogTitle>
|
<ResizableDialogTitle>새 야드 생성</ResizableDialogTitle>
|
||||||
<ResizableDialogDescription>야드 이름을 입력하세요</ResizableDialogDescription>
|
<ResizableDialogDescription>야드 이름을 입력하세요</ResizableDialogDescription>
|
||||||
</div>
|
|
||||||
</ResizableDialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
maxWidth={1400}
|
maxWidth={1400}
|
||||||
maxHeight={900}
|
maxHeight={900}
|
||||||
modalId={`excel-upload-${tableName}`}
|
modalId={`excel-upload-${tableName}`}
|
||||||
userId={userId || "guest"}
|
userId={userId}
|
||||||
>
|
>
|
||||||
<ResizableDialogHeader>
|
<ResizableDialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
ResizableDialog,
|
||||||
ResizableDialogContent,
|
ResizableDialogContent,
|
||||||
|
|
@ -12,7 +12,6 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -27,8 +26,6 @@ interface ScreenModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const { userId } = useAuth();
|
|
||||||
|
|
||||||
const [modalState, setModalState] = useState<ScreenModalState>({
|
const [modalState, setModalState] = useState<ScreenModalState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
screenId: null,
|
screenId: null,
|
||||||
|
|
@ -222,87 +219,27 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
|
|
||||||
const modalStyle = getModalStyle();
|
const modalStyle = getModalStyle();
|
||||||
|
|
||||||
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
|
|
||||||
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
// modalId 생성 및 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
// 모달이 열려있고 screenId가 있을 때만 업데이트
|
|
||||||
if (!modalState.isOpen) return;
|
|
||||||
|
|
||||||
let newModalId: string | undefined;
|
|
||||||
|
|
||||||
// 1순위: screenId (가장 안정적)
|
|
||||||
if (modalState.screenId) {
|
|
||||||
newModalId = `screen-modal-${modalState.screenId}`;
|
|
||||||
console.log("🔑 ScreenModal modalId 생성:", {
|
|
||||||
method: "screenId",
|
|
||||||
screenId: modalState.screenId,
|
|
||||||
result: newModalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 2순위: 테이블명
|
|
||||||
else if (screenData?.screenInfo?.tableName) {
|
|
||||||
newModalId = `screen-modal-table-${screenData.screenInfo.tableName}`;
|
|
||||||
console.log("🔑 ScreenModal modalId 생성:", {
|
|
||||||
method: "tableName",
|
|
||||||
tableName: screenData.screenInfo.tableName,
|
|
||||||
result: newModalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 3순위: 화면명
|
|
||||||
else if (screenData?.screenInfo?.screenName) {
|
|
||||||
newModalId = `screen-modal-name-${screenData.screenInfo.screenName}`;
|
|
||||||
console.log("🔑 ScreenModal modalId 생성:", {
|
|
||||||
method: "screenName",
|
|
||||||
screenName: screenData.screenInfo.screenName,
|
|
||||||
result: newModalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 4순위: 제목
|
|
||||||
else if (modalState.title) {
|
|
||||||
const titleId = modalState.title.replace(/\s+/g, '-');
|
|
||||||
newModalId = `screen-modal-title-${titleId}`;
|
|
||||||
console.log("🔑 ScreenModal modalId 생성:", {
|
|
||||||
method: "title",
|
|
||||||
title: modalState.title,
|
|
||||||
result: newModalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newModalId) {
|
|
||||||
setPersistedModalId(newModalId);
|
|
||||||
}
|
|
||||||
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent
|
<ResizableDialogContent
|
||||||
className={`${modalStyle.className} ${className || ""}`}
|
className={`${modalStyle.className} ${className || ""}`}
|
||||||
style={modalStyle.style}
|
style={modalStyle.style}
|
||||||
defaultWidth={600}
|
defaultWidth={800}
|
||||||
defaultHeight={800}
|
defaultHeight={600}
|
||||||
minWidth={500}
|
minWidth={600}
|
||||||
minHeight={400}
|
minHeight={400}
|
||||||
maxWidth={1600}
|
maxWidth={1400}
|
||||||
maxHeight={1200}
|
maxHeight={1000}
|
||||||
modalId={persistedModalId}
|
modalId={`screen-modal-${modalState.screenId}`}
|
||||||
userId={userId || "guest"}
|
|
||||||
>
|
>
|
||||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
|
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
|
||||||
{modalState.description && !loading && (
|
{modalState.description && !loading && (
|
||||||
<ResizableDialogDescription className="text-muted-foreground text-xs">
|
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
|
||||||
{modalState.description}
|
|
||||||
</ResizableDialogDescription>
|
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<ResizableDialogDescription className="text-xs">
|
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
|
||||||
{loading ? "화면을 불러오는 중입니다..." : ""}
|
|
||||||
</ResizableDialogDescription>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</ResizableDialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||||
|
|
|
||||||
|
|
@ -168,20 +168,20 @@ export function TableOptionsModal({
|
||||||
</ResizableDialogDescription>
|
</ResizableDialogDescription>
|
||||||
</ResizableDialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="columns" className="flex flex-col flex-1 overflow-hidden">
|
<Tabs defaultValue="columns" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3 flex-shrink-0">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="columns" className="text-xs sm:text-sm">컬럼 설정</TabsTrigger>
|
<TabsTrigger value="columns" className="text-xs sm:text-sm">컬럼 설정</TabsTrigger>
|
||||||
<TabsTrigger value="display" className="text-xs sm:text-sm">표시 설정</TabsTrigger>
|
<TabsTrigger value="display" className="text-xs sm:text-sm">표시 설정</TabsTrigger>
|
||||||
<TabsTrigger value="view" className="text-xs sm:text-sm">보기 모드</TabsTrigger>
|
<TabsTrigger value="view" className="text-xs sm:text-sm">보기 모드</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 컬럼 설정 탭 */}
|
{/* 컬럼 설정 탭 */}
|
||||||
<TabsContent value="columns" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4">
|
<TabsContent value="columns" className="space-y-3 sm:space-y-4 mt-4">
|
||||||
<div className="text-xs sm:text-sm text-muted-foreground mb-2">
|
<div className="text-xs sm:text-sm text-muted-foreground mb-2">
|
||||||
드래그하여 순서를 변경하거나, 아이콘을 클릭하여 표시/숨기기를 설정하세요.
|
드래그하여 순서를 변경하거나, 아이콘을 클릭하여 표시/숨기기를 설정하세요.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
{columns.map((column, index) => (
|
{columns.map((column, index) => (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
|
|
@ -249,7 +249,7 @@ export function TableOptionsModal({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 표시 설정 탭 */}
|
{/* 표시 설정 탭 */}
|
||||||
<TabsContent value="display" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4">
|
<TabsContent value="display" className="space-y-3 sm:space-y-4 mt-4">
|
||||||
<div className="flex items-center justify-between p-3 sm:p-4 border rounded-md bg-card">
|
<div className="flex items-center justify-between p-3 sm:p-4 border rounded-md bg-card">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label className="text-xs sm:text-sm font-medium">그리드선 표시</Label>
|
<Label className="text-xs sm:text-sm font-medium">그리드선 표시</Label>
|
||||||
|
|
@ -265,7 +265,7 @@ export function TableOptionsModal({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 보기 모드 탭 */}
|
{/* 보기 모드 탭 */}
|
||||||
<TabsContent value="view" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4">
|
<TabsContent value="view" className="space-y-3 sm:space-y-4 mt-4">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === "table" ? "default" : "outline"}
|
variant={viewMode === "table" ? "default" : "outline"}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
ResizableDialog,
|
||||||
DialogContent,
|
ResizableDialogContent,
|
||||||
DialogHeader,
|
ResizableDialogHeader,
|
||||||
DialogTitle,
|
ResizableDialogTitle,
|
||||||
DialogDescription,
|
ResizableDialogDescription,
|
||||||
DialogFooter,
|
ResizableDialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/resizable-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -101,17 +101,17 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<ResizableDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<ResizableDialogTitle className="flex items-center gap-2">
|
||||||
<Copy className="h-5 w-5" />
|
<Copy className="h-5 w-5" />
|
||||||
화면 복사
|
화면 복사
|
||||||
</DialogTitle>
|
</ResizableDialogTitle>
|
||||||
<DialogDescription>
|
<ResizableDialogDescription>
|
||||||
{sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다.
|
{sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다.
|
||||||
</DialogDescription>
|
</ResizableDialogDescription>
|
||||||
</DialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 원본 화면 정보 */}
|
{/* 원본 화면 정보 */}
|
||||||
|
|
@ -168,7 +168,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<ResizableDialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
|
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -185,8 +185,8 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</ResizableDialogFooter>
|
||||||
</DialogContent>
|
</ResizableDialogContent>
|
||||||
</Dialog>
|
</ResizableDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
minHeight={400}
|
minHeight={400}
|
||||||
maxWidth={1400}
|
maxWidth={1400}
|
||||||
maxHeight={1000}
|
maxHeight={1000}
|
||||||
modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined}
|
modalId={`edit-modal-${modalState.screenId}`}
|
||||||
userId={user?.userId}
|
userId={user?.userId}
|
||||||
>
|
>
|
||||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
|
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
|
||||||
{modalState.description && !loading && (
|
{modalState.description && !loading && (
|
||||||
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
|
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
|
||||||
|
|
@ -317,7 +316,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
{loading && (
|
{loading && (
|
||||||
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
|
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</ResizableDialogHeader>
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||||
|
|
||||||
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
|
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
|
||||||
// 공통코드 옵션 가져오기
|
// 공통코드 옵션 가져오기
|
||||||
const loadCodeOptions = useCallback(
|
const loadCodeOptions = useCallback(
|
||||||
|
|
@ -208,7 +208,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
if (!categoryColumns || categoryColumns.length === 0) return;
|
if (!categoryColumns || categoryColumns.length === 0) return;
|
||||||
|
|
||||||
// 각 카테고리 컬럼의 값 목록 조회
|
// 각 카테고리 컬럼의 값 목록 조회
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
for (const col of categoryColumns) {
|
for (const col of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -217,23 +217,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
// valueCode -> {label, color} 매핑 생성
|
// valueCode -> valueLabel 매핑 생성
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
const mapping: Record<string, string> = {};
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
mapping[item.valueCode] = {
|
mapping[item.valueCode] = item.valueLabel;
|
||||||
label: item.valueLabel,
|
|
||||||
color: item.color,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
mappings[col.columnName] = mapping;
|
mappings[col.columnName] = mapping;
|
||||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
|
// 카테고리 값 로드 실패 시 무시
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📊 전체 카테고리 매핑:", mappings);
|
|
||||||
setCategoryMappings(mappings);
|
setCategoryMappings(mappings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("카테고리 매핑 로드 실패:", error);
|
console.error("카테고리 매핑 로드 실패:", error);
|
||||||
|
|
@ -1916,27 +1911,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
|
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
|
||||||
switch (actualWebType) {
|
switch (actualWebType) {
|
||||||
case "category": {
|
case "category": {
|
||||||
// 카테고리 타입: 배지로 표시
|
// 카테고리 타입: 코드값 -> 라벨로 변환
|
||||||
if (!value) return "";
|
|
||||||
|
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
const categoryData = mapping?.[String(value)];
|
if (mapping && value) {
|
||||||
|
const label = mapping[String(value)];
|
||||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
return label || String(value);
|
||||||
const displayLabel = categoryData?.label || String(value);
|
}
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
return String(value || "");
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
style={{
|
|
||||||
backgroundColor: displayColor,
|
|
||||||
borderColor: displayColor
|
|
||||||
}}
|
|
||||||
className="text-white"
|
|
||||||
>
|
|
||||||
{displayLabel}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "date":
|
case "date":
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog";
|
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -121,67 +120,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
[userName],
|
[userName],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 Enter 키로 다음 필드 이동
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEnterKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
|
|
||||||
// textarea는 제외 (여러 줄 입력)
|
|
||||||
if (target.tagName === "TEXTAREA") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// input, select 등의 폼 요소에서만 작동
|
|
||||||
if (
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "SELECT" ||
|
|
||||||
target.getAttribute("role") === "combobox"
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// 모든 포커스 가능한 요소 찾기
|
|
||||||
const focusableElements = document.querySelectorAll<HTMLElement>(
|
|
||||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬
|
|
||||||
const focusableArray = Array.from(focusableElements).sort((a, b) => {
|
|
||||||
const rectA = a.getBoundingClientRect();
|
|
||||||
const rectB = b.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로)
|
|
||||||
if (Math.abs(rectA.top - rectB.top) > 10) {
|
|
||||||
return rectA.top - rectB.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로)
|
|
||||||
return rectA.left - rectB.left;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentIndex = focusableArray.indexOf(target);
|
|
||||||
|
|
||||||
if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) {
|
|
||||||
// 다음 요소로 포커스 이동
|
|
||||||
const nextElement = focusableArray[currentIndex + 1];
|
|
||||||
nextElement.focus();
|
|
||||||
|
|
||||||
// input이면 전체 선택
|
|
||||||
if (nextElement.tagName === "INPUT") {
|
|
||||||
(nextElement as HTMLInputElement).select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleEnterKey);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleEnterKey);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 🆕 autoFill 자동 입력 초기화
|
// 🆕 autoFill 자동 입력 초기화
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const initAutoInputFields = async () => {
|
const initAutoInputFields = async () => {
|
||||||
|
|
@ -692,17 +630,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
{/* 팝업 화면 렌더링 */}
|
{/* 팝업 화면 렌더링 */}
|
||||||
{popupScreen && (
|
{popupScreen && (
|
||||||
<ResizableDialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||||
<ResizableDialogContent
|
<DialogContent
|
||||||
className="overflow-hidden p-0"
|
className={` ${
|
||||||
defaultWidth={popupScreen.size === "small" ? 600 : popupScreen.size === "large" ? 1400 : 1000}
|
popupScreen.size === "small" ? "max-w-md" : popupScreen.size === "large" ? "max-w-6xl" : "max-w-4xl"
|
||||||
defaultHeight={800}
|
} max-h-[90vh] overflow-y-auto`}
|
||||||
minWidth={500}
|
|
||||||
minHeight={400}
|
|
||||||
maxWidth={1600}
|
|
||||||
maxHeight={1200}
|
|
||||||
modalId={`popup-screen-${popupScreen.screenId}`}
|
|
||||||
userId={user?.userId || "guest"}
|
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{popupScreen.title}</DialogTitle>
|
<DialogTitle>{popupScreen.title}</DialogTitle>
|
||||||
|
|
@ -736,8 +668,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
ResizableDialog,
|
||||||
ResizableDialogContent,
|
ResizableDialogContent,
|
||||||
ResizableDialogHeader,
|
|
||||||
ResizableDialogTitle,
|
|
||||||
ResizableDialogDescription,
|
|
||||||
ResizableDialogFooter,
|
ResizableDialogFooter,
|
||||||
|
ResizableResizableDialogHeader,
|
||||||
|
ResizableResizableDialogTitle,
|
||||||
|
ResizableResizableDialogDescription,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/resizable-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 타이머 정리
|
// 타이머 정리
|
||||||
|
|
@ -407,7 +407,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
화면 목록으로 이동
|
화면 목록으로 이동
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// 기본 할당 화면
|
// 기본 할당 화면
|
||||||
|
|
|
||||||
|
|
@ -833,35 +833,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 현재 화면의 테이블 컬럼 정보 조회
|
// 현재 화면의 테이블 컬럼 정보 조회
|
||||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||||
|
|
||||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
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("🖼️ 이미지 컬럼 발견:", {
|
|
||||||
// columnName: col.columnName || col.column_name,
|
|
||||||
// widgetType,
|
|
||||||
// webType: col.webType || col.web_type,
|
|
||||||
// rawData: col,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
|
||||||
tableName: col.tableName || tableName,
|
tableName: col.tableName || tableName,
|
||||||
columnName: col.columnName || col.column_name,
|
columnName: col.columnName || col.column_name,
|
||||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||||
dataType: col.dataType || col.data_type || col.dbType,
|
dataType: col.dataType || col.data_type || col.dbType,
|
||||||
webType: col.webType || col.web_type,
|
webType: col.webType || col.web_type,
|
||||||
input_type: col.inputType || col.input_type,
|
input_type: col.inputType || col.input_type,
|
||||||
widgetType,
|
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
|
||||||
isNullable: col.isNullable || col.is_nullable,
|
isNullable: col.isNullable || col.is_nullable,
|
||||||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||||
columnDefault: col.columnDefault || col.column_default,
|
columnDefault: col.columnDefault || col.column_default,
|
||||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||||
codeCategory: col.codeCategory || col.code_category,
|
codeCategory: col.codeCategory || col.code_category,
|
||||||
codeValue: col.codeValue || col.code_value,
|
codeValue: col.codeValue || col.code_value,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
const tableInfo: TableInfo = {
|
const tableInfo: TableInfo = {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -2607,16 +2593,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
defaultWidth,
|
defaultWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 이미지 타입 드래그앤드롭 디버깅
|
|
||||||
// if (column.widgetType === "image") {
|
|
||||||
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
|
|
||||||
// columnName: column.columnName,
|
|
||||||
// widgetType: column.widgetType,
|
|
||||||
// componentId,
|
|
||||||
// column,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,12 @@ import {
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
ResizableDialog,
|
||||||
DialogContent,
|
ResizableDialogContent,
|
||||||
DialogHeader,
|
ResizableDialogHeader,
|
||||||
DialogTitle,
|
ResizableDialogTitle,
|
||||||
DialogFooter,
|
ResizableDialogFooter,
|
||||||
DialogDescription,
|
} from "@/components/ui/resizable-dialog";
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
|
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
@ -457,7 +456,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onDesignScreen(screen)}
|
onClick={() => onDesignScreen(screen)}
|
||||||
>
|
>
|
||||||
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
<TableCell className="h-16 cursor-pointer px-6 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{screen.screenName}</div>
|
<div className="font-medium">{screen.screenName}</div>
|
||||||
{screen.description && (
|
{screen.description && (
|
||||||
|
|
@ -697,7 +696,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{deletedScreens.map((screen) => (
|
{deletedScreens.map((screen) => (
|
||||||
<TableRow key={screen.screenId} className="bg-background hover:bg-muted/50 border-b transition-colors">
|
<TableRow
|
||||||
|
key={screen.screenId}
|
||||||
|
className="bg-background hover:bg-muted/50 border-b transition-colors"
|
||||||
|
>
|
||||||
<TableCell className="h-16 px-6 py-3">
|
<TableCell className="h-16 px-6 py-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedScreenIds.includes(screen.screenId)}
|
checked={selectedScreenIds.includes(screen.screenId)}
|
||||||
|
|
@ -1063,11 +1065,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 화면 편집 다이얼로그 */}
|
{/* 화면 편집 다이얼로그 */}
|
||||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
<ResizableDialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<ResizableDialogHeader>
|
||||||
<DialogTitle>화면 정보 편집</DialogTitle>
|
<ResizableDialogTitle>화면 정보 편집</ResizableDialogTitle>
|
||||||
</DialogHeader>
|
</ResizableDialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-screenName">화면명 *</Label>
|
<Label htmlFor="edit-screenName">화면명 *</Label>
|
||||||
|
|
@ -1104,23 +1106,23 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<ResizableDialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
|
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</ResizableDialogFooter>
|
||||||
</DialogContent>
|
</ResizableDialogContent>
|
||||||
</Dialog>
|
</ResizableDialog>
|
||||||
|
|
||||||
{/* 화면 미리보기 다이얼로그 */}
|
{/* 화면 미리보기 다이얼로그 */}
|
||||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
<ResizableDialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||||
<DialogContent className="h-[95vh] max-w-[95vw]">
|
<ResizableDialogContent className="h-[95vh] max-w-[95vw]">
|
||||||
<DialogHeader>
|
<ResizableDialogHeader>
|
||||||
<DialogTitle>화면 미리보기 - {screenToPreview?.screenName}</DialogTitle>
|
<ResizableDialogTitle>화면 미리보기 - {screenToPreview?.screenName}</ResizableDialogTitle>
|
||||||
</DialogHeader>
|
</ResizableDialogHeader>
|
||||||
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
|
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
|
||||||
{isLoadingPreview ? (
|
{isLoadingPreview ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -1270,7 +1272,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
|
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
|
||||||
if (
|
if (
|
||||||
(component.type === "widget" && (component as any).widgetType === "button") ||
|
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||||
(component.type === "component" && (component as any).componentType?.includes("button"))
|
(component.type === "component" &&
|
||||||
|
(component as any).componentType?.includes("button"))
|
||||||
) {
|
) {
|
||||||
console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
|
console.log("🔘 ScreenList 버튼 외부 div 스타일:", {
|
||||||
id: component.id,
|
id: component.id,
|
||||||
|
|
@ -1358,7 +1361,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<ResizableDialogFooter>
|
||||||
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1366,9 +1369,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
<Palette className="mr-2 h-4 w-4" />
|
||||||
편집 모드로 전환
|
편집 모드로 전환
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</ResizableDialogFooter>
|
||||||
</DialogContent>
|
</ResizableDialogContent>
|
||||||
</Dialog>
|
</ResizableDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
||||||
배경 이미지 (CSS)
|
이미지
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="backgroundImage"
|
id="backgroundImage"
|
||||||
|
|
@ -168,9 +168,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||||
className="h-6 w-full px-2 py-0 text-xs"
|
className="h-6 w-full px-2 py-0 text-xs"
|
||||||
style={{ fontSize: "12px" }}
|
style={{ fontSize: "12px" }}
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
위젯 배경 꾸미기용 (고급 사용자 전용)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -318,10 +318,10 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(className)}>
|
<div className={cn("space-y-2", className)}>
|
||||||
{/* 필터 그리드 + 초기화 버튼 한 줄 */}
|
{/* 필터 그리드 - 적절한 너비로 조정 */}
|
||||||
{effectiveFilters.length > 0 && (
|
{effectiveFilters.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{effectiveFilters.map((filter: DataTableFilter) => {
|
{effectiveFilters.map((filter: DataTableFilter) => {
|
||||||
// 필터 개수에 따라 적절한 너비 계산
|
// 필터 개수에 따라 적절한 너비 계산
|
||||||
const getFilterWidth = () => {
|
const getFilterWidth = () => {
|
||||||
|
|
@ -338,14 +338,17 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 필터 초기화 버튼 - 같은 줄에 배치 */}
|
{/* 필터 상태 및 초기화 버튼 */}
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2 flex-shrink-0">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-muted-foreground text-sm">{activeFiltersCount}개 필터 적용 중</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
필터 초기화
|
필터 초기화
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -66,28 +66,6 @@ export function FlowWidget({
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
|
|
||||||
// 숫자 포맷팅 함수
|
|
||||||
const formatValue = (value: any): string => {
|
|
||||||
if (value === null || value === undefined || value === "") {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return value.toLocaleString("ko-KR");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const numValue = parseFloat(value);
|
|
||||||
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
|
|
||||||
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
|
|
||||||
return numValue.toLocaleString("ko-KR");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🆕 전역 상태 관리
|
// 🆕 전역 상태 관리
|
||||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||||
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
||||||
|
|
@ -114,6 +92,40 @@ export function FlowWidget({
|
||||||
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||||
|
|
||||||
|
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨})
|
||||||
|
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
|
||||||
|
// 값 포맷팅 함수 (숫자, 카테고리 등)
|
||||||
|
const formatValue = useCallback((value: any, columnName?: string): string => {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 타입: 코드값 -> 라벨로 변환
|
||||||
|
if (columnName && categoryMappings[columnName]) {
|
||||||
|
const mapping = categoryMappings[columnName];
|
||||||
|
const label = mapping[String(value)];
|
||||||
|
if (label) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
|
||||||
|
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
|
||||||
|
return numValue.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}, [categoryMappings]);
|
||||||
|
|
||||||
// 🆕 그룹 설정 관련 상태
|
// 🆕 그룹 설정 관련 상태
|
||||||
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
||||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
||||||
|
|
@ -370,12 +382,6 @@ export function FlowWidget({
|
||||||
});
|
});
|
||||||
|
|
||||||
setFilteredData(filtered);
|
setFilteredData(filtered);
|
||||||
console.log("🔍 검색 실행:", {
|
|
||||||
totalRows: stepData.length,
|
|
||||||
filteredRows: filtered.length,
|
|
||||||
searchValues,
|
|
||||||
hasSearchValue,
|
|
||||||
});
|
|
||||||
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
|
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
|
||||||
|
|
||||||
// 선택된 스텝의 데이터를 다시 로드하는 함수
|
// 선택된 스텝의 데이터를 다시 로드하는 함수
|
||||||
|
|
@ -459,7 +465,6 @@ export function FlowWidget({
|
||||||
|
|
||||||
// 프리뷰 모드에서는 샘플 데이터만 표시
|
// 프리뷰 모드에서는 샘플 데이터만 표시
|
||||||
if (isPreviewMode) {
|
if (isPreviewMode) {
|
||||||
console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시");
|
|
||||||
setFlowData({
|
setFlowData({
|
||||||
id: flowId || 0,
|
id: flowId || 0,
|
||||||
flowName: flowName || "샘플 플로우",
|
flowName: flowName || "샘플 플로우",
|
||||||
|
|
@ -636,16 +641,9 @@ export function FlowWidget({
|
||||||
try {
|
try {
|
||||||
// 컬럼 라벨 조회
|
// 컬럼 라벨 조회
|
||||||
const labelsResponse = await getStepColumnLabels(flowId!, stepId);
|
const labelsResponse = await getStepColumnLabels(flowId!, stepId);
|
||||||
console.log("🏷️ 컬럼 라벨 조회 결과:", {
|
|
||||||
stepId,
|
|
||||||
success: labelsResponse.success,
|
|
||||||
labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0,
|
|
||||||
labels: labelsResponse.data,
|
|
||||||
});
|
|
||||||
if (labelsResponse.success && labelsResponse.data) {
|
if (labelsResponse.success && labelsResponse.data) {
|
||||||
setColumnLabels(labelsResponse.data);
|
setColumnLabels(labelsResponse.data);
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse);
|
|
||||||
setColumnLabels({});
|
setColumnLabels({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -677,6 +675,61 @@ export function FlowWidget({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼의 값 매핑 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategoryMappings = async () => {
|
||||||
|
if (!selectedStepId || !steps.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentStep = steps.find((s) => s.id === selectedStepId);
|
||||||
|
const tableName = currentStep?.stepConfig?.tableName;
|
||||||
|
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
// 테이블 컬럼 정보 조회하여 카테고리 타입 찾기
|
||||||
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||||
|
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
|
||||||
|
if (!columnsResponse.data?.success) return;
|
||||||
|
|
||||||
|
const columns = columnsResponse.data.data?.columns || [];
|
||||||
|
const categoryColumns = columns.filter((col: any) =>
|
||||||
|
(col.inputType === "category" || col.input_type === "category")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryColumns.length === 0) return;
|
||||||
|
|
||||||
|
// 각 카테고리 컬럼의 값 목록 조회
|
||||||
|
const mappings: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
for (const col of categoryColumns) {
|
||||||
|
const columnName = col.columnName || col.column_name;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/table-categories/${tableName}/${columnName}/values`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
response.data.data.forEach((item: any) => {
|
||||||
|
mapping[item.valueCode] = item.valueLabel;
|
||||||
|
});
|
||||||
|
mappings[columnName] = mapping;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 카테고리 값 로드 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategoryMappings(mappings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("FlowWidget 카테고리 매핑 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCategoryMappings();
|
||||||
|
}, [selectedStepId, steps]);
|
||||||
|
|
||||||
// 체크박스 토글
|
// 체크박스 토글
|
||||||
const toggleRowSelection = (rowIndex: number) => {
|
const toggleRowSelection = (rowIndex: number) => {
|
||||||
// 프리뷰 모드에서는 행 선택 차단
|
// 프리뷰 모드에서는 행 선택 차단
|
||||||
|
|
@ -694,13 +747,6 @@ export function FlowWidget({
|
||||||
|
|
||||||
// 선택된 데이터를 상위로 전달
|
// 선택된 데이터를 상위로 전달
|
||||||
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
|
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
|
||||||
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
|
|
||||||
rowIndex,
|
|
||||||
newSelectedSize: newSelected.size,
|
|
||||||
selectedData,
|
|
||||||
selectedStepId,
|
|
||||||
hasCallback: !!onSelectedDataChange,
|
|
||||||
});
|
|
||||||
onSelectedDataChange?.(selectedData, selectedStepId);
|
onSelectedDataChange?.(selectedData, selectedStepId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1017,7 +1063,7 @@ export function FlowWidget({
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||||
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
||||||
<span className="text-foreground truncate">{formatValue(row[col])}</span>
|
<span className="text-foreground truncate">{formatValue(row[col], col)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1095,7 +1141,7 @@ export function FlowWidget({
|
||||||
)}
|
)}
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||||
{formatValue(row[col])}
|
{formatValue(row[col], col)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -1125,7 +1171,7 @@ export function FlowWidget({
|
||||||
)}
|
)}
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||||
{formatValue(row[col])}
|
{formatValue(row[col], col)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useRef, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
|
||||||
import { WidgetComponent } from "@/types/screen";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
|
||||||
|
|
||||||
export const ImageWidget: React.FC<WebTypeComponentProps> = ({
|
|
||||||
component,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
readonly = false,
|
|
||||||
isDesignMode = false // 디자인 모드 여부
|
|
||||||
}) => {
|
|
||||||
const widget = component as WidgetComponent;
|
|
||||||
const { required, style } = widget;
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
|
||||||
// 이미지 URL 처리 (백엔드 서버 경로로 변환)
|
|
||||||
const rawImageUrl = value || widget.value || "";
|
|
||||||
const imageUrl = rawImageUrl ? getFullImageUrl(rawImageUrl) : "";
|
|
||||||
|
|
||||||
// style에서 width, height 제거 (부모 컨테이너 크기 사용)
|
|
||||||
const filteredStyle = style ? { ...style, width: undefined, height: undefined } : {};
|
|
||||||
|
|
||||||
// 파일 선택 처리
|
|
||||||
const handleFileSelect = () => {
|
|
||||||
// 디자인 모드에서는 업로드 불가
|
|
||||||
if (readonly || isDesignMode) return;
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 파일 업로드 처리
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// 이미지 파일 검증
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("이미지 파일만 업로드 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 파일 크기 검증 (5MB)
|
|
||||||
const maxSize = 5 * 1024 * 1024;
|
|
||||||
if (file.size > maxSize) {
|
|
||||||
toast.error("파일 크기는 최대 5MB까지 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// FormData 생성
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("files", file);
|
|
||||||
formData.append("docType", "IMAGE");
|
|
||||||
formData.append("docTypeName", "이미지");
|
|
||||||
|
|
||||||
// 서버에 업로드 (axios 사용 - 인증 토큰 자동 포함)
|
|
||||||
const response = await apiClient.post("/files/upload", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success && response.data.files && response.data.files.length > 0) {
|
|
||||||
const uploadedFile = response.data.files[0];
|
|
||||||
const imageUrl = uploadedFile.filePath; // /uploads/company_*/2024/01/01/filename.jpg
|
|
||||||
onChange?.(imageUrl);
|
|
||||||
toast.success("이미지가 업로드되었습니다.");
|
|
||||||
} else {
|
|
||||||
throw new Error(response.data.message || "업로드 실패");
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("이미지 업로드 오류:", error);
|
|
||||||
const errorMessage = error.response?.data?.message || error.message || "이미지 업로드에 실패했습니다.";
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 이미지 제거
|
|
||||||
const handleRemove = () => {
|
|
||||||
// 디자인 모드에서는 제거 불가
|
|
||||||
if (readonly || isDesignMode) return;
|
|
||||||
onChange?.("");
|
|
||||||
toast.success("이미지가 제거되었습니다.");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 드래그 앤 드롭 처리
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// 디자인 모드에서는 드롭 불가
|
|
||||||
if (readonly || isDesignMode) return;
|
|
||||||
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// 파일 input에 파일 설정
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(file);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.files = dataTransfer.files;
|
|
||||||
handleFileChange({ target: fileInputRef.current } as any);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
{imageUrl ? (
|
|
||||||
// 이미지 표시 모드
|
|
||||||
<div
|
|
||||||
className="group relative h-full w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md"
|
|
||||||
style={filteredStyle}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt="업로드된 이미지"
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 호버 시 제거 버튼 */}
|
|
||||||
{!readonly && !isDesignMode && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleRemove}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
이미지 제거
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 업로드 영역
|
|
||||||
<div
|
|
||||||
className={`group relative flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
|
|
||||||
isDesignMode
|
|
||||||
? "cursor-default border-gray-200 bg-gray-50"
|
|
||||||
: "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md"
|
|
||||||
}`}
|
|
||||||
onClick={handleFileSelect}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
style={filteredStyle}
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<p className="text-xs font-medium text-blue-600">업로드 중...</p>
|
|
||||||
) : readonly ? (
|
|
||||||
<p className="text-xs font-medium text-gray-500">업로드 불가</p>
|
|
||||||
) : isDesignMode ? (
|
|
||||||
<p className="text-xs font-medium text-gray-400">이미지 업로드</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs font-medium text-gray-700 transition-colors duration-300 group-hover:text-blue-600">
|
|
||||||
이미지 업로드
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 숨겨진 파일 input */}
|
|
||||||
<Input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
disabled={readonly || uploading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 필수 필드 경고 */}
|
|
||||||
{required && !imageUrl && (
|
|
||||||
<div className="text-xs text-red-500">* 이미지를 업로드해야 합니다</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ImageWidget.displayName = "ImageWidget";
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { TextareaWidget } from "./TextareaWidget";
|
||||||
import { CheckboxWidget } from "./CheckboxWidget";
|
import { CheckboxWidget } from "./CheckboxWidget";
|
||||||
import { RadioWidget } from "./RadioWidget";
|
import { RadioWidget } from "./RadioWidget";
|
||||||
import { FileWidget } from "./FileWidget";
|
import { FileWidget } from "./FileWidget";
|
||||||
import { ImageWidget } from "./ImageWidget";
|
|
||||||
import { CodeWidget } from "./CodeWidget";
|
import { CodeWidget } from "./CodeWidget";
|
||||||
import { EntityWidget } from "./EntityWidget";
|
import { EntityWidget } from "./EntityWidget";
|
||||||
import { RatingWidget } from "./RatingWidget";
|
import { RatingWidget } from "./RatingWidget";
|
||||||
|
|
@ -25,7 +24,6 @@ export { TextareaWidget } from "./TextareaWidget";
|
||||||
export { CheckboxWidget } from "./CheckboxWidget";
|
export { CheckboxWidget } from "./CheckboxWidget";
|
||||||
export { RadioWidget } from "./RadioWidget";
|
export { RadioWidget } from "./RadioWidget";
|
||||||
export { FileWidget } from "./FileWidget";
|
export { FileWidget } from "./FileWidget";
|
||||||
export { ImageWidget } from "./ImageWidget";
|
|
||||||
export { CodeWidget } from "./CodeWidget";
|
export { CodeWidget } from "./CodeWidget";
|
||||||
export { EntityWidget } from "./EntityWidget";
|
export { EntityWidget } from "./EntityWidget";
|
||||||
export { RatingWidget } from "./RatingWidget";
|
export { RatingWidget } from "./RatingWidget";
|
||||||
|
|
@ -49,8 +47,6 @@ export const getWidgetComponentByName = (componentName: string): React.Component
|
||||||
return RadioWidget;
|
return RadioWidget;
|
||||||
case "FileWidget":
|
case "FileWidget":
|
||||||
return FileWidget;
|
return FileWidget;
|
||||||
case "ImageWidget":
|
|
||||||
return ImageWidget;
|
|
||||||
case "CodeWidget":
|
case "CodeWidget":
|
||||||
return CodeWidget;
|
return CodeWidget;
|
||||||
case "EntityWidget":
|
case "EntityWidget":
|
||||||
|
|
@ -109,12 +105,6 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp
|
||||||
case "attachment":
|
case "attachment":
|
||||||
return FileWidget;
|
return FileWidget;
|
||||||
|
|
||||||
case "image":
|
|
||||||
case "img":
|
|
||||||
case "picture":
|
|
||||||
case "photo":
|
|
||||||
return ImageWidget;
|
|
||||||
|
|
||||||
case "code":
|
case "code":
|
||||||
case "script":
|
case "script":
|
||||||
return CodeWidget;
|
return CodeWidget;
|
||||||
|
|
@ -165,7 +155,6 @@ export const WebTypeComponents: Record<string, React.ComponentType<WebTypeCompon
|
||||||
checkbox: CheckboxWidget,
|
checkbox: CheckboxWidget,
|
||||||
radio: RadioWidget,
|
radio: RadioWidget,
|
||||||
file: FileWidget,
|
file: FileWidget,
|
||||||
image: ImageWidget,
|
|
||||||
code: CodeWidget,
|
code: CodeWidget,
|
||||||
entity: EntityWidget,
|
entity: EntityWidget,
|
||||||
rating: RatingWidget,
|
rating: RatingWidget,
|
||||||
|
|
|
||||||
|
|
@ -11,33 +11,9 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||||
|
|
||||||
// 기본 색상 팔레트
|
|
||||||
const DEFAULT_COLORS = [
|
|
||||||
"#ef4444", // red
|
|
||||||
"#f97316", // orange
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#eab308", // yellow
|
|
||||||
"#84cc16", // lime
|
|
||||||
"#22c55e", // green
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#0ea5e9", // sky
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#d946ef", // fuchsia
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#64748b", // slate
|
|
||||||
"#6b7280", // gray
|
|
||||||
];
|
|
||||||
|
|
||||||
interface CategoryValueAddDialogProps {
|
interface CategoryValueAddDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -50,7 +26,6 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
|
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
|
||||||
const [valueLabel, setValueLabel] = useState("");
|
const [valueLabel, setValueLabel] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [color, setColor] = useState("#3b82f6");
|
|
||||||
|
|
||||||
// 라벨에서 코드 자동 생성
|
// 라벨에서 코드 자동 생성
|
||||||
const generateCode = (label: string): string => {
|
const generateCode = (label: string): string => {
|
||||||
|
|
@ -84,14 +59,13 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
valueCode,
|
valueCode,
|
||||||
valueLabel: valueLabel.trim(),
|
valueLabel: valueLabel.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
color: color,
|
color: "#3b82f6",
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 초기화
|
// 초기화
|
||||||
setValueLabel("");
|
setValueLabel("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setColor("#3b82f6");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,57 +81,24 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
|
||||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
|
||||||
이름
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="valueLabel"
|
id="valueLabel"
|
||||||
placeholder="예: 개발, 긴급, 진행중"
|
placeholder="이름 (예: 개발, 긴급, 진행중)"
|
||||||
value={valueLabel}
|
value={valueLabel}
|
||||||
onChange={(e) => setValueLabel(e.target.value)}
|
onChange={(e) => setValueLabel(e.target.value)}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs sm:text-sm">배지 색상</Label>
|
|
||||||
<div className="mt-1.5 flex items-center gap-3">
|
|
||||||
<div className="grid grid-cols-9 gap-2">
|
|
||||||
{DEFAULT_COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setColor(c)}
|
|
||||||
className={`h-7 w-7 rounded-md border-2 transition-all ${
|
|
||||||
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: c }}
|
|
||||||
title={c}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
|
|
||||||
미리보기
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
||||||
설명 (선택사항)
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
placeholder="설명을 입력하세요"
|
placeholder="설명 (선택사항)"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
className="text-xs sm:text-sm mt-1.5"
|
className="text-xs sm:text-sm"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||||
|
|
||||||
interface CategoryValueEditDialogProps {
|
interface CategoryValueEditDialogProps {
|
||||||
|
|
@ -24,39 +22,15 @@ interface CategoryValueEditDialogProps {
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 색상 팔레트
|
|
||||||
const DEFAULT_COLORS = [
|
|
||||||
"#ef4444", // red
|
|
||||||
"#f97316", // orange
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#eab308", // yellow
|
|
||||||
"#84cc16", // lime
|
|
||||||
"#22c55e", // green
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#0ea5e9", // sky
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#d946ef", // fuchsia
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#64748b", // slate
|
|
||||||
"#6b7280", // gray
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CategoryValueEditDialog: React.FC<
|
export const CategoryValueEditDialog: React.FC<
|
||||||
CategoryValueEditDialogProps
|
CategoryValueEditDialogProps
|
||||||
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
|
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
|
||||||
const [valueLabel, setValueLabel] = useState(value.valueLabel);
|
const [valueLabel, setValueLabel] = useState(value.valueLabel);
|
||||||
const [description, setDescription] = useState(value.description || "");
|
const [description, setDescription] = useState(value.description || "");
|
||||||
const [color, setColor] = useState(value.color || "#3b82f6");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValueLabel(value.valueLabel);
|
setValueLabel(value.valueLabel);
|
||||||
setDescription(value.description || "");
|
setDescription(value.description || "");
|
||||||
setColor(value.color || "#3b82f6");
|
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
|
@ -67,7 +41,6 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
onUpdate(value.valueId!, {
|
onUpdate(value.valueId!, {
|
||||||
valueLabel: valueLabel.trim(),
|
valueLabel: valueLabel.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
color: color,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -84,57 +57,24 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
|
||||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
|
||||||
이름
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="valueLabel"
|
id="valueLabel"
|
||||||
placeholder="예: 개발, 긴급, 진행중"
|
placeholder="이름 (예: 개발, 긴급, 진행중)"
|
||||||
value={valueLabel}
|
value={valueLabel}
|
||||||
onChange={(e) => setValueLabel(e.target.value)}
|
onChange={(e) => setValueLabel(e.target.value)}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs sm:text-sm">배지 색상</Label>
|
|
||||||
<div className="mt-1.5 flex items-center gap-3">
|
|
||||||
<div className="grid grid-cols-9 gap-2">
|
|
||||||
{DEFAULT_COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setColor(c)}
|
|
||||||
className={`h-7 w-7 rounded-md border-2 transition-all ${
|
|
||||||
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: c }}
|
|
||||||
title={c}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
|
|
||||||
미리보기
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
||||||
설명 (선택사항)
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
placeholder="설명을 입력하세요"
|
placeholder="설명 (선택사항)"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
className="text-xs sm:text-sm mt-1.5"
|
className="text-xs sm:text-sm"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 🆕 Context를 사용하여 open 상태 공유
|
const ResizableDialog = DialogPrimitive.Root;
|
||||||
const ResizableDialogContext = React.createContext<{ open: boolean }>({ open: false });
|
|
||||||
|
|
||||||
// 🆕 ResizableDialog를 래핑하여 Context 제공
|
|
||||||
const ResizableDialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
|
||||||
children,
|
|
||||||
open = false,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<ResizableDialogContext.Provider value={{ open }}>
|
|
||||||
<DialogPrimitive.Root open={open} {...props}>
|
|
||||||
{children}
|
|
||||||
</DialogPrimitive.Root>
|
|
||||||
</ResizableDialogContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ResizableDialogTrigger = DialogPrimitive.Trigger;
|
const ResizableDialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
|
@ -54,7 +38,6 @@ interface ResizableDialogContentProps
|
||||||
defaultHeight?: number;
|
defaultHeight?: number;
|
||||||
modalId?: string; // localStorage 저장용 고유 ID
|
modalId?: string; // localStorage 저장용 고유 ID
|
||||||
userId?: string; // 사용자별 저장용
|
userId?: string; // 사용자별 저장용
|
||||||
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResizableDialogContent = React.forwardRef<
|
const ResizableDialogContent = React.forwardRef<
|
||||||
|
|
@ -67,13 +50,12 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
children,
|
children,
|
||||||
minWidth = 400,
|
minWidth = 400,
|
||||||
minHeight = 300,
|
minHeight = 300,
|
||||||
maxWidth = 1600,
|
maxWidth = 1400,
|
||||||
maxHeight = 1200,
|
maxHeight = 900,
|
||||||
defaultWidth = 600,
|
defaultWidth = 600,
|
||||||
defaultHeight = 500,
|
defaultHeight = 500,
|
||||||
modalId,
|
modalId,
|
||||||
userId = "guest",
|
userId = "guest",
|
||||||
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
|
|
||||||
style: userStyle,
|
style: userStyle,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -87,7 +69,6 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
if (!stableIdRef.current) {
|
if (!stableIdRef.current) {
|
||||||
if (modalId) {
|
if (modalId) {
|
||||||
stableIdRef.current = modalId;
|
stableIdRef.current = modalId;
|
||||||
// // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
|
|
||||||
} else {
|
} else {
|
||||||
// className 기반 ID 생성
|
// className 기반 ID 생성
|
||||||
if (className) {
|
if (className) {
|
||||||
|
|
@ -95,7 +76,6 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
return ((acc << 5) - acc) + char.charCodeAt(0);
|
return ((acc << 5) - acc) + char.charCodeAt(0);
|
||||||
}, 0);
|
}, 0);
|
||||||
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
||||||
// console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
|
|
||||||
} else if (userStyle) {
|
} else if (userStyle) {
|
||||||
// userStyle 기반 ID 생성
|
// userStyle 기반 ID 생성
|
||||||
const styleStr = JSON.stringify(userStyle);
|
const styleStr = JSON.stringify(userStyle);
|
||||||
|
|
@ -103,11 +83,9 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
return ((acc << 5) - acc) + char.charCodeAt(0);
|
return ((acc << 5) - acc) + char.charCodeAt(0);
|
||||||
}, 0);
|
}, 0);
|
||||||
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
||||||
// console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
|
|
||||||
} else {
|
} else {
|
||||||
// 기본 ID
|
// 기본 ID
|
||||||
stableIdRef.current = 'modal-default';
|
stableIdRef.current = 'modal-default';
|
||||||
// console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,133 +132,39 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
const [isResizing, setIsResizing] = React.useState(false);
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
const [resizeDirection, setResizeDirection] = React.useState<string>("");
|
const [resizeDirection, setResizeDirection] = React.useState<string>("");
|
||||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||||
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
|
|
||||||
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
|
|
||||||
|
|
||||||
// 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open)
|
|
||||||
const context = React.useContext(ResizableDialogContext);
|
|
||||||
const actualOpen = externalOpen !== undefined ? externalOpen : context.open;
|
|
||||||
|
|
||||||
// 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋
|
|
||||||
const [wasOpen, setWasOpen] = React.useState(false);
|
|
||||||
|
|
||||||
|
// 모달이 열릴 때 초기 크기 설정 (localStorage 우선, 없으면 화면관리 설정)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId });
|
|
||||||
|
|
||||||
if (actualOpen && !wasOpen) {
|
|
||||||
// 모달이 방금 열림
|
|
||||||
// console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
|
|
||||||
setIsInitialized(false);
|
|
||||||
setWasOpen(true);
|
|
||||||
} else if (!actualOpen && wasOpen) {
|
|
||||||
// 모달이 방금 닫힘
|
|
||||||
// console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
|
|
||||||
setWasOpen(false);
|
|
||||||
}
|
|
||||||
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
|
|
||||||
|
|
||||||
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (effectiveModalId !== lastModalId) {
|
|
||||||
// console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized });
|
|
||||||
setIsInitialized(false);
|
|
||||||
setUserResized(false); // 사용자 리사이징 플래그도 리셋
|
|
||||||
setLastModalId(effectiveModalId);
|
|
||||||
}
|
|
||||||
}, [effectiveModalId, lastModalId, isInitialized]);
|
|
||||||
|
|
||||||
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
|
|
||||||
React.useEffect(() => {
|
|
||||||
// console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
|
||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
|
||||||
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 10;
|
|
||||||
|
|
||||||
const measureContent = () => {
|
|
||||||
attempts++;
|
|
||||||
|
|
||||||
// scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
|
||||||
let contentWidth = defaultWidth;
|
|
||||||
let contentHeight = defaultHeight;
|
|
||||||
|
|
||||||
if (contentRef.current) {
|
|
||||||
// scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
|
||||||
contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
|
||||||
contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
|
||||||
|
|
||||||
// console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
|
||||||
} else {
|
|
||||||
// console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
|
||||||
|
|
||||||
// contentRef가 아직 없으면 재시도
|
|
||||||
if (attempts < maxAttempts) {
|
|
||||||
setTimeout(measureContent, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 패딩 추가 (p-6 * 2 = 48px)
|
|
||||||
const paddingAndMargin = 48;
|
|
||||||
const initialSize = getInitialSize();
|
const initialSize = getInitialSize();
|
||||||
|
|
||||||
// 내용 크기 기반 최소 크기 계산
|
// localStorage에서 저장된 크기가 있는지 확인
|
||||||
const contentBasedSize = {
|
|
||||||
width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
|
||||||
height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log("📐 내용 기반 크기:", contentBasedSize);
|
|
||||||
|
|
||||||
// localStorage에서 저장된 크기 확인
|
|
||||||
let finalSize = contentBasedSize;
|
|
||||||
|
|
||||||
if (effectiveModalId && typeof window !== 'undefined') {
|
if (effectiveModalId && typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||||
const saved = localStorage.getItem(storageKey);
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
// console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
|
||||||
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
|
// 저장된 크기가 있으면 그것을 사용 (사용자가 이전에 리사이즈한 크기)
|
||||||
// userResized 플래그 확인
|
const restoredSize = {
|
||||||
if (parsed.userResized) {
|
width: Math.max(minWidth, Math.min(maxWidth, parsed.width || initialSize.width)),
|
||||||
const savedSize = {
|
height: Math.max(minHeight, Math.min(maxHeight, parsed.height || initialSize.height)),
|
||||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
|
||||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
|
||||||
};
|
};
|
||||||
|
setSize(restoredSize);
|
||||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
setIsInitialized(true);
|
||||||
|
return;
|
||||||
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
|
||||||
// (사용자가 의도적으로 작게 만든 것을 존중)
|
|
||||||
finalSize = savedSize;
|
|
||||||
setUserResized(true);
|
|
||||||
|
|
||||||
// console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
|
||||||
} else {
|
|
||||||
// console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("❌ 모달 크기 복원 실패:", error);
|
console.error("모달 크기 복원 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSize(finalSize);
|
// 저장된 크기가 없으면 초기 크기 사용 (화면관리 설정 크기)
|
||||||
|
setSize(initialSize);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
};
|
|
||||||
|
|
||||||
// 첫 시도는 300ms 후에 시작
|
|
||||||
setTimeout(measureContent, 300);
|
|
||||||
}
|
}
|
||||||
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight]);
|
||||||
|
|
||||||
const startResize = (direction: string) => (e: React.MouseEvent) => {
|
const startResize = (direction: string) => (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -322,28 +206,14 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
// 사용자가 리사이징했음을 표시
|
// localStorage에 크기 저장 (리사이즈 후 새로고침해도 유지)
|
||||||
setUserResized(true);
|
if (effectiveModalId && typeof window !== 'undefined') {
|
||||||
|
|
||||||
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
|
|
||||||
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
|
|
||||||
try {
|
try {
|
||||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||||
|
const currentSize = { width: size.width, height: size.height };
|
||||||
// contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용
|
|
||||||
const modalElement = contentRef.current.parentElement;
|
|
||||||
const actualWidth = modalElement?.offsetWidth || size.width;
|
|
||||||
const actualHeight = modalElement?.offsetHeight || size.height;
|
|
||||||
|
|
||||||
const currentSize = {
|
|
||||||
width: actualWidth,
|
|
||||||
height: actualHeight,
|
|
||||||
userResized: true, // 사용자가 직접 리사이징했음을 표시
|
|
||||||
};
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(currentSize));
|
localStorage.setItem(storageKey, JSON.stringify(currentSize));
|
||||||
// console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("❌ 모달 크기 저장 실패:", error);
|
console.error("모달 크기 저장 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -373,7 +243,7 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
minHeight: `${minHeight}px`,
|
minHeight: `${minHeight}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={contentRef} className="flex flex-col h-full overflow-auto">
|
<div ref={contentRef} className="flex flex-col h-full overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,26 +20,17 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
|
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
|
|
||||||
// 디버깅: 이미지 타입만 로깅
|
// 디버깅: 전달받은 웹타입과 props 정보 로깅
|
||||||
// if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") {
|
if (webType === "button") {
|
||||||
// console.log(`🖼️ DynamicWebTypeRenderer 이미지 호출: webType="${webType}"`, {
|
console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
|
||||||
// component: props.component,
|
webType,
|
||||||
// readonly: props.readonly,
|
component: props.component,
|
||||||
// value: props.value,
|
position: props.component?.position,
|
||||||
// widgetType: props.component?.widgetType,
|
size: props.component?.size,
|
||||||
// });
|
style: props.component?.style,
|
||||||
// }
|
config,
|
||||||
|
});
|
||||||
// if (webType === "button") {
|
}
|
||||||
// console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
|
|
||||||
// webType,
|
|
||||||
// component: props.component,
|
|
||||||
// position: props.component?.position,
|
|
||||||
// size: props.component?.size,
|
|
||||||
// style: props.component?.style,
|
|
||||||
// config,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
const webTypeDefinition = useMemo(() => {
|
const webTypeDefinition = useMemo(() => {
|
||||||
return WebTypeRegistry.getWebType(webType);
|
return WebTypeRegistry.getWebType(webType);
|
||||||
|
|
@ -73,35 +64,23 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
if (webType === "file" || props.component?.type === "file") {
|
if (webType === "file" || props.component?.type === "file") {
|
||||||
try {
|
try {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
// console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
|
console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("FileUploadComponent 로드 실패:", error);
|
console.error("FileUploadComponent 로드 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 컴포넌트 강제 처리
|
|
||||||
if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") {
|
|
||||||
try {
|
|
||||||
// console.log(`🎯 이미지 컴포넌트 감지! webType: ${webType}`, { props, finalProps });
|
|
||||||
const { ImageWidget } = require("@/components/screen/widgets/types/ImageWidget");
|
|
||||||
// console.log(`✅ ImageWidget 로드 성공`);
|
|
||||||
return <ImageWidget {...props} {...finalProps} />;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ ImageWidget 로드 실패:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
|
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
|
||||||
if (dbWebType?.component_name) {
|
if (dbWebType?.component_name) {
|
||||||
try {
|
try {
|
||||||
// console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
||||||
// console.log("DB 웹타입 정보:", dbWebType);
|
console.log("DB 웹타입 정보:", dbWebType);
|
||||||
|
|
||||||
// FileWidget의 경우 FileUploadComponent 직접 사용
|
// FileWidget의 경우 FileUploadComponent 직접 사용
|
||||||
if (dbWebType.component_name === "FileWidget" || webType === "file") {
|
if (dbWebType.component_name === "FileWidget" || webType === "file") {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
// console.log("✅ FileWidget → FileUploadComponent 사용");
|
console.log("✅ FileWidget → FileUploadComponent 사용");
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +88,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
|
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
|
||||||
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
||||||
// return <ComponentByName {...props} {...finalProps} />;
|
// return <ComponentByName {...props} {...finalProps} />;
|
||||||
// console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
|
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
|
||||||
|
|
||||||
// 로딩 중 메시지 대신 레지스트리로 폴백
|
// 로딩 중 메시지 대신 레지스트리로 폴백
|
||||||
// return <div>컴포넌트 로딩 중...</div>;
|
// return <div>컴포넌트 로딩 중...</div>;
|
||||||
|
|
@ -120,18 +99,18 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
|
|
||||||
// 2순위: 레지스트리에 등록된 웹타입 사용
|
// 2순위: 레지스트리에 등록된 웹타입 사용
|
||||||
if (webTypeDefinition) {
|
if (webTypeDefinition) {
|
||||||
// console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
|
console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
|
||||||
|
|
||||||
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
|
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
|
||||||
if (webType === "file") {
|
if (webType === "file") {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
// console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
|
console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 웹타입이 비활성화된 경우
|
// 웹타입이 비활성화된 경우
|
||||||
if (!webTypeDefinition.isActive) {
|
if (!webTypeDefinition.isActive) {
|
||||||
// console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
|
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-dashed border-yellow-500/30 bg-yellow-50 p-3 dark:bg-yellow-950/20">
|
<div className="rounded-md border border-dashed border-yellow-500/30 bg-yellow-50 p-3 dark:bg-yellow-950/20">
|
||||||
<div className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
|
<div className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
|
||||||
|
|
@ -159,28 +138,28 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
|
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
|
||||||
if (webType === "file") {
|
if (webType === "file") {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
// console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
|
console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 텍스트 입력 웹타입들
|
// 텍스트 입력 웹타입들
|
||||||
if (["text", "email", "password", "tel"].includes(webType)) {
|
if (["text", "email", "password", "tel"].includes(webType)) {
|
||||||
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
|
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
|
||||||
// console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
|
console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
|
||||||
return <TextInputComponent {...props} {...finalProps} />;
|
return <TextInputComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 숫자 입력 웹타입들
|
// 숫자 입력 웹타입들
|
||||||
if (["number", "decimal"].includes(webType)) {
|
if (["number", "decimal"].includes(webType)) {
|
||||||
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
|
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
|
||||||
// console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
|
console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
|
||||||
return <NumberInputComponent {...props} {...finalProps} />;
|
return <NumberInputComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 날짜 입력 웹타입들
|
// 날짜 입력 웹타입들
|
||||||
if (["date", "datetime", "time"].includes(webType)) {
|
if (["date", "datetime", "time"].includes(webType)) {
|
||||||
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
|
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
|
||||||
// console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
|
console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
|
||||||
return <DateInputComponent {...props} {...finalProps} />;
|
return <DateInputComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,7 +173,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 기본 폴백: Input 컴포넌트 사용
|
// 기본 폴백: Input 컴포넌트 사용
|
||||||
const { Input } = require("@/components/ui/input");
|
const { Input } = require("@/components/ui/input");
|
||||||
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
||||||
// console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
|
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
|
||||||
const safeFallbackProps = filterDOMProps(props);
|
const safeFallbackProps = filterDOMProps(props);
|
||||||
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
|
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -35,35 +35,12 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
// 동적 웹타입 렌더링 사용
|
// 동적 웹타입 렌더링 사용
|
||||||
if (widgetType) {
|
if (widgetType) {
|
||||||
try {
|
try {
|
||||||
// 파일 위젯만 디자인 모드에서 인터랙션 허용
|
// 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거)
|
||||||
// 이미지 위젯은 실행 모드(모달)에서만 업로드 가능하도록 함
|
|
||||||
const isFileWidget = widgetType === "file";
|
const isFileWidget = widgetType === "file";
|
||||||
const isImageWidget = widgetType === "image" || widgetType === "img" || widgetType === "picture" || widgetType === "photo";
|
|
||||||
const allowInteraction = isFileWidget;
|
|
||||||
|
|
||||||
// 이미지 위젯은 래퍼 없이 직접 렌더링 (크기 문제 해결)
|
|
||||||
if (isImageWidget) {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none h-full w-full">
|
|
||||||
<DynamicWebTypeRenderer
|
|
||||||
webType={widgetType}
|
|
||||||
props={{
|
|
||||||
...commonProps,
|
|
||||||
component: widget,
|
|
||||||
value: undefined, // 미리보기이므로 값은 없음
|
|
||||||
readonly: readonly,
|
|
||||||
isDesignMode: true, // 디자인 모드임을 명시
|
|
||||||
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
|
|
||||||
}}
|
|
||||||
config={widget.webTypeConfig}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className={allowInteraction ? "flex-1" : "pointer-events-none flex-1"}>
|
<div className={isFileWidget ? "flex-1" : "pointer-events-none flex-1"}>
|
||||||
<DynamicWebTypeRenderer
|
<DynamicWebTypeRenderer
|
||||||
webType={widgetType}
|
webType={widgetType}
|
||||||
props={{
|
props={{
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface ImageWidgetConfigPanelProps {
|
|
||||||
config: any;
|
|
||||||
onConfigChange: (config: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 위젯 설정 패널
|
|
||||||
*/
|
|
||||||
export function ImageWidgetConfigPanel({ config, onConfigChange }: ImageWidgetConfigPanelProps) {
|
|
||||||
const handleChange = (key: string, value: any) => {
|
|
||||||
onConfigChange({
|
|
||||||
...config,
|
|
||||||
[key]: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">이미지 설정</CardTitle>
|
|
||||||
<CardDescription className="text-xs">이미지 업로드 및 표시 설정</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="maxSize" className="text-xs">
|
|
||||||
최대 파일 크기 (MB)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="maxSize"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
value={(config.maxSize || 5 * 1024 * 1024) / (1024 * 1024)}
|
|
||||||
onChange={(e) => handleChange("maxSize", parseInt(e.target.value) * 1024 * 1024)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="placeholder" className="text-xs">
|
|
||||||
플레이스홀더 텍스트
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="placeholder"
|
|
||||||
type="text"
|
|
||||||
value={config.placeholder || "이미지를 업로드하세요"}
|
|
||||||
onChange={(e) => handleChange("placeholder", e.target.value)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
|
||||||
<p className="mb-1 font-medium">지원 형식:</p>
|
|
||||||
<p>JPG, PNG, GIF, WebP</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImageWidgetConfigPanel;
|
|
||||||
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
|
||||||
import { ImageWidgetDefinition } from "./index";
|
|
||||||
import { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ImageWidget 렌더러
|
|
||||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
|
||||||
*/
|
|
||||||
export class ImageWidgetRenderer extends AutoRegisteringComponentRenderer {
|
|
||||||
static componentDefinition = ImageWidgetDefinition;
|
|
||||||
|
|
||||||
render(): React.ReactElement {
|
|
||||||
return <ImageWidget {...this.props} renderer={this} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컴포넌트별 특화 메서드들
|
|
||||||
*/
|
|
||||||
|
|
||||||
// image 타입 특화 속성 처리
|
|
||||||
protected getImageWidgetProps() {
|
|
||||||
const baseProps = this.getWebTypeProps();
|
|
||||||
|
|
||||||
// image 타입에 특화된 추가 속성들
|
|
||||||
return {
|
|
||||||
...baseProps,
|
|
||||||
// 여기에 image 타입 특화 속성들 추가
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 값 변경 처리
|
|
||||||
protected handleValueChange = (value: any) => {
|
|
||||||
this.updateComponent({ value });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 포커스 처리
|
|
||||||
protected handleFocus = () => {
|
|
||||||
// 포커스 로직
|
|
||||||
};
|
|
||||||
|
|
||||||
// 블러 처리
|
|
||||||
protected handleBlur = () => {
|
|
||||||
// 블러 로직
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자동 등록 실행
|
|
||||||
ImageWidgetRenderer.registerSelf();
|
|
||||||
|
|
||||||
// Hot Reload 지원 (개발 모드)
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
ImageWidgetRenderer.enableHotReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
|
||||||
import { ComponentCategory } from "@/types/component";
|
|
||||||
import type { WebType } from "@/types/screen";
|
|
||||||
import { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
|
|
||||||
import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ImageWidget 컴포넌트 정의
|
|
||||||
* image-widget 컴포넌트입니다
|
|
||||||
*/
|
|
||||||
export const ImageWidgetDefinition = createComponentDefinition({
|
|
||||||
id: "image-widget",
|
|
||||||
name: "이미지 위젯",
|
|
||||||
nameEng: "Image Widget",
|
|
||||||
description: "이미지 표시 및 업로드",
|
|
||||||
category: ComponentCategory.INPUT,
|
|
||||||
webType: "image",
|
|
||||||
component: ImageWidget,
|
|
||||||
defaultConfig: {
|
|
||||||
type: "image-widget",
|
|
||||||
webType: "image",
|
|
||||||
maxSize: 5 * 1024 * 1024, // 5MB
|
|
||||||
acceptedFormats: ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
|
||||||
},
|
|
||||||
defaultSize: { width: 200, height: 200 },
|
|
||||||
configPanel: ImageWidgetConfigPanel,
|
|
||||||
icon: "Image",
|
|
||||||
tags: ["image", "upload", "media", "picture", "photo"],
|
|
||||||
version: "1.0.0",
|
|
||||||
author: "개발팀",
|
|
||||||
documentation: "https://docs.example.com/components/image-widget",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컴포넌트 내보내기
|
|
||||||
export { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
|
|
||||||
export { ImageWidgetRenderer } from "./ImageWidgetRenderer";
|
|
||||||
|
|
||||||
|
|
@ -28,7 +28,6 @@ import "./date-input/DateInputRenderer";
|
||||||
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
|
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
|
||||||
import "./text-display/TextDisplayRenderer";
|
import "./text-display/TextDisplayRenderer";
|
||||||
import "./file-upload/FileUploadRenderer";
|
import "./file-upload/FileUploadRenderer";
|
||||||
import "./image-widget/ImageWidgetRenderer";
|
|
||||||
import "./slider-basic/SliderBasicRenderer";
|
import "./slider-basic/SliderBasicRenderer";
|
||||||
import "./toggle-switch/ToggleSwitchRenderer";
|
import "./toggle-switch/ToggleSwitchRenderer";
|
||||||
import "./image-display/ImageDisplayRenderer";
|
import "./image-display/ImageDisplayRenderer";
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,14 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm"
|
className="relative h-full overflow-x-auto overflow-y-auto bg-background shadow-sm backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%", // 최대 높이 제한으로 스크롤 활성화
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex-1 overflow-x-auto overflow-y-auto">
|
|
||||||
<Table
|
<Table
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -78,15 +78,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
<TableHeader
|
<TableHeader
|
||||||
className={
|
className={
|
||||||
tableConfig.stickyHeader
|
tableConfig.stickyHeader
|
||||||
? "sticky top-0 border-b shadow-md"
|
? "sticky top-0 z-20 border-b bg-background backdrop-blur-sm"
|
||||||
: "border-b"
|
: "border-b bg-background backdrop-blur-sm"
|
||||||
}
|
}
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TableRow className="border-b">
|
<TableRow className="border-b">
|
||||||
{actualColumns.map((column, colIndex) => {
|
{actualColumns.map((column, colIndex) => {
|
||||||
|
|
@ -109,15 +103,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background"
|
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3"
|
||||||
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background",
|
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
column.sortable && "hover:bg-primary/10",
|
column.sortable && "hover:bg-primary/10",
|
||||||
// 고정 컬럼 스타일
|
// 고정 컬럼 스타일
|
||||||
column.fixed === "left" &&
|
column.fixed === "left" &&
|
||||||
"sticky z-40 border-r border-border bg-background shadow-sm",
|
"sticky z-10 border-r border-border bg-background shadow-sm",
|
||||||
column.fixed === "right" &&
|
column.fixed === "right" &&
|
||||||
"sticky z-40 border-l border-border bg-background shadow-sm",
|
"sticky z-10 border-l border-border bg-background shadow-sm",
|
||||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||||
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||||
)}
|
)}
|
||||||
|
|
@ -129,7 +123,6 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
// sticky 위치 설정
|
// sticky 위치 설정
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
|
|
@ -253,6 +246,5 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { codeCache } from "@/lib/caching/codeCache";
|
import { codeCache } from "@/lib/caching/codeCache";
|
||||||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||||
import { getFullImageUrl } from "@/lib/api/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -142,14 +141,7 @@ export interface TableListComponentProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
screenId?: string;
|
screenId?: string;
|
||||||
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||||||
onSelectedRowsChange?: (
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||||
selectedRows: any[],
|
|
||||||
selectedRowsData: any[],
|
|
||||||
sortBy?: string,
|
|
||||||
sortOrder?: "asc" | "desc",
|
|
||||||
columnOrder?: string[],
|
|
||||||
tableDisplayData?: any[],
|
|
||||||
) => void;
|
|
||||||
onConfigChange?: (config: any) => void;
|
onConfigChange?: (config: any) => void;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -194,9 +186,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 객체인 경우 tableName 속성 추출 시도
|
// 객체인 경우 tableName 속성 추출 시도
|
||||||
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
||||||
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
|
|
||||||
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
|
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
|
||||||
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tableConfig.selectedTable = finalSelectedTable;
|
tableConfig.selectedTable = finalSelectedTable;
|
||||||
|
|
@ -252,13 +242,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
||||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
||||||
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
||||||
const [columnMeta, setColumnMeta] = useState<
|
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string; inputType?: string }>>({});
|
||||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
|
||||||
>({});
|
|
||||||
const [categoryMappings, setCategoryMappings] = useState<
|
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
|
||||||
>({});
|
|
||||||
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
|
@ -288,20 +273,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableConfig.selectedTable || !userId) return;
|
if (!tableConfig.selectedTable || !userId) return;
|
||||||
|
|
||||||
const userKey = userId || "guest";
|
const userKey = userId || 'guest';
|
||||||
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
|
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
|
||||||
const savedOrder = localStorage.getItem(storageKey);
|
const savedOrder = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
if (savedOrder) {
|
if (savedOrder) {
|
||||||
try {
|
try {
|
||||||
const parsedOrder = JSON.parse(savedOrder);
|
const parsedOrder = JSON.parse(savedOrder);
|
||||||
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
|
|
||||||
setColumnOrder(parsedOrder);
|
setColumnOrder(parsedOrder);
|
||||||
|
|
||||||
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
||||||
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
||||||
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
|
|
||||||
|
|
||||||
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
||||||
const initialData = data.map((row: any) => {
|
const initialData = data.map((row: any) => {
|
||||||
const reordered: any = {};
|
const reordered: any = {};
|
||||||
|
|
@ -319,16 +301,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return reordered;
|
return reordered;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
|
|
||||||
|
|
||||||
// 전역 저장소에 데이터 저장
|
// 전역 저장소에 데이터 저장
|
||||||
if (tableConfig.selectedTable) {
|
if (tableConfig.selectedTable) {
|
||||||
tableDisplayStore.setTableData(
|
tableDisplayStore.setTableData(
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
initialData,
|
initialData,
|
||||||
parsedOrder.filter((col) => col !== "__checkbox__"),
|
parsedOrder.filter(col => col !== '__checkbox__'),
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,22 +338,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const cached = tableColumnCache.get(cacheKey);
|
const cached = tableColumnCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
|
||||||
|
|
||||||
// 캐시된 inputTypes 맵 생성
|
|
||||||
const inputTypeMap: Record<string, string> = {};
|
|
||||||
if (cached.inputTypes) {
|
|
||||||
cached.inputTypes.forEach((col: any) => {
|
|
||||||
inputTypeMap[col.columnName] = col.inputType;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cached.columns.forEach((col: any) => {
|
cached.columns.forEach((col: any) => {
|
||||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||||
meta[col.columnName] = {
|
meta[col.columnName] = {
|
||||||
webType: col.webType,
|
webType: col.webType,
|
||||||
codeCategory: col.codeCategory,
|
codeCategory: col.codeCategory,
|
||||||
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -455,130 +426,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 카테고리 값 매핑 로드
|
// 카테고리 값 매핑 로드
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// 카테고리 컬럼 목록 추출 (useMemo로 최적화)
|
useEffect(() => {
|
||||||
const categoryColumns = useMemo(() => {
|
const loadCategoryMappings = async () => {
|
||||||
const cols = Object.entries(columnMeta)
|
if (!tableConfig.selectedTable || !columnMeta) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categoryColumns = Object.entries(columnMeta)
|
||||||
.filter(([_, meta]) => meta.inputType === "category")
|
.filter(([_, meta]) => meta.inputType === "category")
|
||||||
.map(([columnName, _]) => columnName);
|
.map(([columnName, _]) => columnName);
|
||||||
|
|
||||||
console.log("🔍 [TableList] 카테고리 컬럼 추출:", {
|
if (categoryColumns.length === 0) return;
|
||||||
columnMeta,
|
|
||||||
categoryColumns: cols,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
return cols;
|
const mappings: Record<string, Record<string, string>> = {};
|
||||||
}, [columnMeta]);
|
|
||||||
|
|
||||||
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
|
||||||
useEffect(() => {
|
|
||||||
const loadCategoryMappings = async () => {
|
|
||||||
console.log("🔄 [TableList] loadCategoryMappings 트리거:", {
|
|
||||||
hasTable: !!tableConfig.selectedTable,
|
|
||||||
table: tableConfig.selectedTable,
|
|
||||||
categoryColumnsLength: categoryColumns.length,
|
|
||||||
categoryColumns,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tableConfig.selectedTable) {
|
|
||||||
console.log("⏭️ [TableList] 테이블 선택 안됨, 카테고리 매핑 로드 스킵");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categoryColumns.length === 0) {
|
|
||||||
console.log("⏭️ [TableList] 카테고리 컬럼 없음, 카테고리 매핑 로드 스킵");
|
|
||||||
setCategoryMappings({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🚀 [TableList] 카테고리 매핑 로드 시작:", {
|
|
||||||
table: tableConfig.selectedTable,
|
|
||||||
categoryColumns,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
|
||||||
|
|
||||||
for (const columnName of categoryColumns) {
|
for (const columnName of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
|
|
||||||
url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||||
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
|
const response = await apiClient.get(
|
||||||
|
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
|
||||||
console.log(`📡 [TableList] API 응답 [${columnName}]:`, {
|
);
|
||||||
success: response.data.success,
|
|
||||||
dataLength: response.data.data?.length,
|
|
||||||
rawData: response.data,
|
|
||||||
items: response.data.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
// valueCode를 문자열로 변환하여 키로 사용
|
mapping[item.valueCode] = item.valueLabel;
|
||||||
const key = String(item.valueCode);
|
|
||||||
mapping[key] = {
|
|
||||||
label: item.valueLabel,
|
|
||||||
color: item.color,
|
|
||||||
};
|
|
||||||
console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(mapping).length > 0) {
|
|
||||||
mappings[columnName] = mapping;
|
mappings[columnName] = mapping;
|
||||||
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
|
|
||||||
columnName,
|
|
||||||
mappingCount: Object.keys(mapping).length,
|
|
||||||
mappingKeys: Object.keys(mapping),
|
|
||||||
mapping,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, {
|
|
||||||
success: response.data.success,
|
|
||||||
hasData: !!response.data.data,
|
|
||||||
isArray: Array.isArray(response.data.data),
|
|
||||||
response: response.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
response: error.response?.data,
|
|
||||||
status: error.response?.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
|
|
||||||
mappingsCount: Object.keys(mappings).length,
|
|
||||||
mappingsKeys: Object.keys(mappings),
|
|
||||||
mappings,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(mappings).length > 0) {
|
|
||||||
setCategoryMappings(mappings);
|
|
||||||
setCategoryMappingsKey((prev) => prev + 1);
|
|
||||||
console.log("✅ [TableList] setCategoryMappings 호출 완료");
|
|
||||||
} else {
|
|
||||||
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error);
|
// 카테고리 값 로드 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategoryMappings(mappings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TableListComponent 카테고리 매핑 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCategoryMappings();
|
loadCategoryMappings();
|
||||||
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns)]); // 더 명확한 의존성
|
}, [tableConfig.selectedTable, columnMeta]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 데이터 가져오기
|
// 데이터 가져오기
|
||||||
|
|
@ -690,8 +577,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column: string) => {
|
const handleSort = (column: string) => {
|
||||||
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
|
|
||||||
|
|
||||||
let newSortColumn = column;
|
let newSortColumn = column;
|
||||||
let newSortDirection: "asc" | "desc" = "asc";
|
let newSortDirection: "asc" | "desc" = "asc";
|
||||||
|
|
||||||
|
|
@ -705,9 +590,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
newSortDirection = "asc";
|
newSortDirection = "asc";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
|
|
||||||
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
|
|
||||||
|
|
||||||
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
|
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
||||||
|
|
@ -736,7 +618,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const bStr = String(bVal).toLowerCase();
|
const bStr = String(bVal).toLowerCase();
|
||||||
|
|
||||||
// 자연스러운 정렬 (숫자 포함 문자열)
|
// 자연스러운 정렬 (숫자 포함 문자열)
|
||||||
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" });
|
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
return newSortDirection === "desc" ? -comparison : comparison;
|
return newSortDirection === "desc" ? -comparison : comparison;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -757,40 +639,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return reordered;
|
return reordered;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 정렬 정보 전달:", {
|
|
||||||
selectedRowsCount: selectedRows.size,
|
|
||||||
selectedRowsDataCount: selectedRowsData.length,
|
|
||||||
sortBy: newSortColumn,
|
|
||||||
sortOrder: newSortDirection,
|
|
||||||
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
|
|
||||||
tableDisplayDataCount: reorderedData.length,
|
|
||||||
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
|
|
||||||
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
|
|
||||||
});
|
|
||||||
onSelectedRowsChange(
|
onSelectedRowsChange(
|
||||||
Array.from(selectedRows),
|
Array.from(selectedRows),
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
newSortColumn,
|
newSortColumn,
|
||||||
newSortDirection,
|
newSortDirection,
|
||||||
columnOrder.length > 0 ? columnOrder : undefined,
|
columnOrder.length > 0 ? columnOrder : undefined,
|
||||||
reorderedData,
|
reorderedData
|
||||||
);
|
);
|
||||||
|
|
||||||
// 전역 저장소에 정렬된 데이터 저장
|
// 전역 저장소에 정렬된 데이터 저장
|
||||||
if (tableConfig.selectedTable) {
|
if (tableConfig.selectedTable) {
|
||||||
const cleanColumnOrder = (
|
const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : visibleColumns.map(c => c.columnName)).filter(col => col !== '__checkbox__');
|
||||||
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
|
||||||
).filter((col) => col !== "__checkbox__");
|
|
||||||
tableDisplayStore.setTableData(
|
tableDisplayStore.setTableData(
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
reorderedData,
|
reorderedData,
|
||||||
cleanColumnOrder,
|
cleanColumnOrder,
|
||||||
newSortColumn,
|
newSortColumn,
|
||||||
newSortDirection,
|
newSortDirection
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -803,16 +671,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
fetchTableDataDebounced();
|
fetchTableDataDebounced();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAdvancedFilters = useCallback(() => {
|
const handleClearAdvancedFilters = () => {
|
||||||
console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues });
|
|
||||||
|
|
||||||
// 상태를 초기화하고 useEffect로 데이터 새로고침
|
|
||||||
setSearchValues({});
|
setSearchValues({});
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
fetchTableDataDebounced();
|
||||||
// 강제로 데이터 새로고침 트리거
|
};
|
||||||
setRefreshTrigger((prev) => prev + 1);
|
|
||||||
}, [searchValues]);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
fetchTableDataDebounced();
|
fetchTableDataDebounced();
|
||||||
|
|
@ -887,8 +750,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||||
|
|
||||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||||
|
|
||||||
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
|
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
|
||||||
|
|
@ -927,17 +788,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// columnOrder 상태가 있으면 그 순서대로 정렬
|
// columnOrder 상태가 있으면 그 순서대로 정렬
|
||||||
if (columnOrder.length > 0) {
|
if (columnOrder.length > 0) {
|
||||||
const orderedCols = columnOrder
|
const orderedCols = columnOrder
|
||||||
.map((colName) => cols.find((c) => c.columnName === colName))
|
.map(colName => cols.find(c => c.columnName === colName))
|
||||||
.filter(Boolean) as ColumnConfig[];
|
.filter(Boolean) as ColumnConfig[];
|
||||||
|
|
||||||
// columnOrder에 없는 새로운 컬럼들 추가
|
// columnOrder에 없는 새로운 컬럼들 추가
|
||||||
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
|
const remainingCols = cols.filter(c => !columnOrder.includes(c.columnName));
|
||||||
|
|
||||||
console.log("🔄 columnOrder 기반 정렬:", {
|
|
||||||
columnOrder,
|
|
||||||
orderedColsCount: orderedCols.length,
|
|
||||||
remainingColsCount: remainingCols.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...orderedCols, ...remainingCols];
|
return [...orderedCols, ...remainingCols];
|
||||||
}
|
}
|
||||||
|
|
@ -949,41 +804,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const lastColumnOrderRef = useRef<string>("");
|
const lastColumnOrderRef = useRef<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
|
||||||
hasCallback: !!onSelectedRowsChange,
|
|
||||||
visibleColumnsLength: visibleColumns.length,
|
|
||||||
visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!onSelectedRowsChange) {
|
if (!onSelectedRowsChange) {
|
||||||
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibleColumns.length === 0) {
|
if (visibleColumns.length === 0) {
|
||||||
console.warn("⚠️ visibleColumns가 비어있습니다!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
const currentColumnOrder = visibleColumns
|
||||||
|
.map(col => col.columnName)
|
||||||
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
.filter(name => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
||||||
|
|
||||||
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
||||||
const columnOrderString = currentColumnOrder.join(",");
|
const columnOrderString = currentColumnOrder.join(",");
|
||||||
console.log("🔍 [컬럼 순서] 비교:", {
|
|
||||||
current: columnOrderString,
|
|
||||||
last: lastColumnOrderRef.current,
|
|
||||||
isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (columnOrderString === lastColumnOrderRef.current) {
|
if (columnOrderString === lastColumnOrderRef.current) {
|
||||||
console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastColumnOrderRef.current = columnOrderString;
|
lastColumnOrderRef.current = columnOrderString;
|
||||||
console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
|
||||||
|
|
||||||
// 선택된 행 데이터 가져오기
|
// 선택된 행 데이터 가져오기
|
||||||
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
||||||
|
|
@ -1011,9 +851,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
currentColumnOrder,
|
currentColumnOrder,
|
||||||
reorderedData,
|
reorderedData
|
||||||
);
|
);
|
||||||
}, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화
|
}, [visibleColumns.length, visibleColumns.map(c => c.columnName).join(",")]); // 의존성 단순화
|
||||||
|
|
||||||
const getColumnWidth = (column: ColumnConfig) => {
|
const getColumnWidth = (column: ColumnConfig) => {
|
||||||
if (column.columnName === "__checkbox__") return 50;
|
if (column.columnName === "__checkbox__") return 50;
|
||||||
|
|
@ -1080,56 +920,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||||
const inputType = meta?.inputType || column.inputType;
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
|
||||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
// 카테고리 타입: 코드값 → 라벨로 변환
|
||||||
if (inputType === "image" && value && typeof value === "string") {
|
|
||||||
const imageUrl = getFullImageUrl(value);
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt="이미지"
|
|
||||||
className="h-10 w-10 rounded object-cover"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src =
|
|
||||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카테고리 타입: 배지로 표시
|
|
||||||
if (inputType === "category") {
|
if (inputType === "category") {
|
||||||
if (!value) return "";
|
|
||||||
|
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
const categoryData = mapping?.[String(value)];
|
if (mapping && value) {
|
||||||
|
const label = mapping[String(value)];
|
||||||
console.log(`🎨 [카테고리 배지] ${column.columnName}:`, {
|
if (label) {
|
||||||
value,
|
return label;
|
||||||
stringValue: String(value),
|
}
|
||||||
mapping,
|
}
|
||||||
categoryData,
|
return String(value);
|
||||||
hasMapping: !!mapping,
|
|
||||||
hasCategoryData: !!categoryData,
|
|
||||||
allCategoryMappings: categoryMappings, // 전체 매핑 확인
|
|
||||||
categoryMappingsKeys: Object.keys(categoryMappings),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
|
||||||
const displayLabel = categoryData?.label || String(value);
|
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
|
||||||
|
|
||||||
const { Badge } = require("@/components/ui/badge");
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
style={{
|
|
||||||
backgroundColor: displayColor,
|
|
||||||
borderColor: displayColor,
|
|
||||||
}}
|
|
||||||
className="text-white"
|
|
||||||
>
|
|
||||||
{displayLabel}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 코드 타입: 코드 값 → 코드명 변환
|
// 코드 타입: 코드 값 → 코드명 변환
|
||||||
|
|
@ -1188,7 +988,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[columnMeta, optimizedConvertCode, categoryMappings],
|
[columnMeta, categoryMappings, optimizedConvertCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -1307,19 +1107,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 사용자 옵션 저장 핸들러
|
// 사용자 옵션 저장 핸들러
|
||||||
const handleTableOptionsSave = useCallback(
|
const handleTableOptionsSave = useCallback((config: {
|
||||||
(config: {
|
|
||||||
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
|
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
|
||||||
showGridLines: boolean;
|
showGridLines: boolean;
|
||||||
viewMode: "table" | "card" | "grouped-card";
|
viewMode: "table" | "card" | "grouped-card";
|
||||||
}) => {
|
}) => {
|
||||||
// 컬럼 순서 업데이트
|
// 컬럼 순서 업데이트
|
||||||
const newColumnOrder = config.columns.map((col) => col.columnName);
|
const newColumnOrder = config.columns.map(col => col.columnName);
|
||||||
setColumnOrder(newColumnOrder);
|
setColumnOrder(newColumnOrder);
|
||||||
|
|
||||||
// 컬럼 너비 업데이트
|
// 컬럼 너비 업데이트
|
||||||
const newWidths: Record<string, number> = {};
|
const newWidths: Record<string, number> = {};
|
||||||
config.columns.forEach((col) => {
|
config.columns.forEach(col => {
|
||||||
if (col.width) {
|
if (col.width) {
|
||||||
newWidths[col.columnName] = col.width;
|
newWidths[col.columnName] = col.width;
|
||||||
}
|
}
|
||||||
|
|
@ -1327,7 +1126,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setColumnWidths(newWidths);
|
setColumnWidths(newWidths);
|
||||||
|
|
||||||
// 틀고정 컬럼 업데이트
|
// 틀고정 컬럼 업데이트
|
||||||
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
|
const newFrozenColumns = config.columns.filter(col => col.frozen).map(col => col.columnName);
|
||||||
setFrozenColumns(newFrozenColumns);
|
setFrozenColumns(newFrozenColumns);
|
||||||
|
|
||||||
// 그리드선 표시 업데이트
|
// 그리드선 표시 업데이트
|
||||||
|
|
@ -1337,8 +1136,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setViewMode(config.viewMode);
|
setViewMode(config.viewMode);
|
||||||
|
|
||||||
// 컬럼 표시/숨기기 업데이트
|
// 컬럼 표시/숨기기 업데이트
|
||||||
const newDisplayColumns = displayColumns.map((col) => {
|
const newDisplayColumns = displayColumns.map(col => {
|
||||||
const configCol = config.columns.find((c) => c.columnName === col.columnName);
|
const configCol = config.columns.find(c => c.columnName === col.columnName);
|
||||||
if (configCol) {
|
if (configCol) {
|
||||||
return { ...col, visible: configCol.visible };
|
return { ...col, visible: configCol.visible };
|
||||||
}
|
}
|
||||||
|
|
@ -1347,9 +1146,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setDisplayColumns(newDisplayColumns);
|
setDisplayColumns(newDisplayColumns);
|
||||||
|
|
||||||
toast.success("테이블 옵션이 저장되었습니다");
|
toast.success("테이블 옵션이 저장되었습니다");
|
||||||
},
|
}, [displayColumns]);
|
||||||
[displayColumns],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 그룹 펼치기/접기 토글
|
// 그룹 펼치기/접기 토글
|
||||||
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||||
|
|
@ -1441,9 +1238,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
searchValues, // 필터 값 변경 시에도 데이터 새로고침
|
|
||||||
refreshKey,
|
refreshKey,
|
||||||
refreshTrigger, // 강제 새로고침 트리거
|
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
fetchTableDataDebounced,
|
fetchTableDataDebounced,
|
||||||
]);
|
]);
|
||||||
|
|
@ -1498,9 +1293,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
|
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
|
<div
|
||||||
|
className="w-full h-14 flex items-center justify-center relative border-t-2 border-border bg-background px-4 flex-shrink-0 sm:h-[60px] sm:px-6"
|
||||||
|
>
|
||||||
{/* 중앙 페이지네이션 컨트롤 */}
|
{/* 중앙 페이지네이션 컨트롤 */}
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
<div
|
||||||
|
className="flex items-center gap-2 sm:gap-4"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1520,7 +1319,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
|
<span className="text-xs font-medium text-foreground min-w-[60px] text-center sm:text-sm sm:min-w-[80px]">
|
||||||
{currentPage} / {totalPages || 1}
|
{currentPage} / {totalPages || 1}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
@ -1543,7 +1342,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 text-[10px] sm:ml-4 sm:text-xs">
|
<span className="text-[10px] text-muted-foreground ml-2 sm:text-xs sm:ml-4">
|
||||||
전체 {totalItems.toLocaleString()}개
|
전체 {totalItems.toLocaleString()}개
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1596,15 +1395,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return (
|
return (
|
||||||
<div {...domProps}>
|
<div {...domProps}>
|
||||||
{tableConfig.filter?.enabled && (
|
{tableConfig.filter?.enabled && (
|
||||||
<div className="border-border border-b px-4 py-2 sm:px-6 sm:py-2">
|
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AdvancedSearchFilters
|
<AdvancedSearchFilters
|
||||||
filters={activeFilters}
|
filters={activeFilters}
|
||||||
searchValues={searchValues}
|
searchValues={searchValues}
|
||||||
onSearchValueChange={handleSearchValueChange}
|
onSearchValueChange={handleSearchValueChange}
|
||||||
onSearch={handleAdvancedSearch}
|
onSearch={handleAdvancedSearch}
|
||||||
onClearFilters={handleClearAdvancedFilters}
|
onClear={handleClearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -1612,7 +1411,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsTableOptionsOpen(true)}
|
onClick={() => setIsTableOptionsOpen(true)}
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
>
|
>
|
||||||
<TableIcon className="mr-2 h-4 w-4" />
|
<TableIcon className="mr-2 h-4 w-4" />
|
||||||
테이블 옵션
|
테이블 옵션
|
||||||
|
|
@ -1621,7 +1420,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
필터 설정
|
필터 설정
|
||||||
|
|
@ -1630,7 +1429,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsGroupSettingOpen(true)}
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
>
|
>
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
그룹 설정
|
그룹 설정
|
||||||
|
|
@ -1642,7 +1441,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
{/* 그룹 표시 배지 */}
|
{/* 그룹 표시 배지 */}
|
||||||
{groupByColumns.length > 0 && (
|
{groupByColumns.length > 0 && (
|
||||||
<div className="border-border bg-muted/30 border-b px-4 py-1.5 sm:px-6">
|
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
<span className="text-muted-foreground">그룹:</span>
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -1666,7 +1465,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}>
|
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px`, flex: 1, overflow: "hidden" }}>
|
||||||
<SingleTableWithSticky
|
<SingleTableWithSticky
|
||||||
data={data}
|
data={data}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
|
|
@ -1703,7 +1502,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<div {...domProps}>
|
<div {...domProps}>
|
||||||
{/* 필터 */}
|
{/* 필터 */}
|
||||||
{tableConfig.filter?.enabled && (
|
{tableConfig.filter?.enabled && (
|
||||||
<div className="border-border flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AdvancedSearchFilters
|
<AdvancedSearchFilters
|
||||||
|
|
@ -1711,7 +1510,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
searchValues={searchValues}
|
searchValues={searchValues}
|
||||||
onSearchValueChange={handleSearchValueChange}
|
onSearchValueChange={handleSearchValueChange}
|
||||||
onSearch={handleAdvancedSearch}
|
onSearch={handleAdvancedSearch}
|
||||||
onClearFilters={handleClearAdvancedFilters}
|
onClear={handleClearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -1719,7 +1518,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsTableOptionsOpen(true)}
|
onClick={() => setIsTableOptionsOpen(true)}
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
>
|
>
|
||||||
<TableIcon className="mr-2 h-4 w-4" />
|
<TableIcon className="mr-2 h-4 w-4" />
|
||||||
테이블 옵션
|
테이블 옵션
|
||||||
|
|
@ -1728,7 +1527,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
onClick={() => setIsFilterSettingOpen(true)}
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
필터 설정
|
필터 설정
|
||||||
|
|
@ -1737,7 +1536,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsGroupSettingOpen(true)}
|
onClick={() => setIsGroupSettingOpen(true)}
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||||
>
|
>
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
그룹 설정
|
그룹 설정
|
||||||
|
|
@ -1749,7 +1548,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
{/* 그룹 표시 배지 */}
|
{/* 그룹 표시 배지 */}
|
||||||
{groupByColumns.length > 0 && (
|
{groupByColumns.length > 0 && (
|
||||||
<div className="border-border bg-muted/30 border-b px-4 py-2 sm:px-6">
|
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
<span className="text-muted-foreground">그룹:</span>
|
<span className="text-muted-foreground">그룹:</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -1775,17 +1574,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
{/* 테이블 컨테이너 */}
|
{/* 테이블 컨테이너 */}
|
||||||
<div
|
<div
|
||||||
className="flex w-full max-w-full flex-1 flex-col overflow-hidden"
|
className="flex-1 flex flex-col overflow-hidden w-full max-w-full"
|
||||||
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px` }}
|
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px` }}
|
||||||
>
|
>
|
||||||
{/* 스크롤 영역 */}
|
{/* 스크롤 영역 */}
|
||||||
<div
|
<div
|
||||||
className="bg-background h-[400px] w-full max-w-full overflow-x-auto overflow-y-scroll sm:h-[500px]"
|
className="w-full max-w-full flex-1 overflow-y-auto overflow-x-auto bg-background"
|
||||||
style={{ position: "relative" }}
|
|
||||||
>
|
>
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<table
|
<table
|
||||||
className={cn("table-mobile-fixed w-full max-w-full", !showGridLines && "hide-grid")}
|
className={cn(
|
||||||
|
"w-full max-w-full table-mobile-fixed",
|
||||||
|
!showGridLines && "hide-grid"
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
@ -1794,20 +1595,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
>
|
>
|
||||||
{/* 헤더 (sticky) */}
|
{/* 헤더 (sticky) */}
|
||||||
<thead
|
<thead
|
||||||
className="sticky z-50"
|
className="sticky top-0 z-10"
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: "-2px",
|
|
||||||
zIndex: 50,
|
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<tr
|
|
||||||
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "hsl(var(--muted))",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<tr className="h-10 border-b-2 border-primary/20 bg-muted sm:h-12">
|
||||||
{visibleColumns.map((column, columnIndex) => {
|
{visibleColumns.map((column, columnIndex) => {
|
||||||
const columnWidth = columnWidths[column.columnName];
|
const columnWidth = columnWidths[column.columnName];
|
||||||
const isFrozen = frozenColumns.includes(column.columnName);
|
const isFrozen = frozenColumns.includes(column.columnName);
|
||||||
|
|
@ -1828,26 +1618,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
|
"relative h-8 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||||
column.sortable !== false &&
|
(column.sortable !== false && column.columnName !== "__checkbox__") && "cursor-pointer hover:bg-muted/70 transition-colors",
|
||||||
column.columnName !== "__checkbox__" &&
|
isFrozen && "sticky z-20 bg-muted/80 backdrop-blur-sm shadow-[2px_0_4px_rgba(0,0,0,0.1)]"
|
||||||
"hover:bg-muted/70 cursor-pointer transition-colors",
|
|
||||||
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
||||||
width:
|
width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
|
||||||
column.columnName === "__checkbox__"
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
? "48px"
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
: columnWidth
|
userSelect: 'none',
|
||||||
? `${columnWidth}px`
|
...(isFrozen && { left: `${leftPosition}px` })
|
||||||
: undefined,
|
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
||||||
userSelect: "none",
|
|
||||||
backgroundColor: "hsl(var(--muted))",
|
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isResizing.current) return;
|
if (isResizing.current) return;
|
||||||
|
|
@ -1869,8 +1651,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
{/* 리사이즈 핸들 (체크박스 제외) */}
|
{/* 리사이즈 핸들 (체크박스 제외) */}
|
||||||
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||||
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||||
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1885,8 +1667,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const startWidth = columnWidth || thElement.offsetWidth;
|
const startWidth = columnWidth || thElement.offsetWidth;
|
||||||
|
|
||||||
// 드래그 중 텍스트 선택 방지
|
// 드래그 중 텍스트 선택 방지
|
||||||
document.body.style.userSelect = "none";
|
document.body.style.userSelect = 'none';
|
||||||
document.body.style.cursor = "col-resize";
|
document.body.style.cursor = 'col-resize';
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
moveEvent.preventDefault();
|
moveEvent.preventDefault();
|
||||||
|
|
@ -1904,24 +1686,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 최종 너비를 state에 저장
|
// 최종 너비를 state에 저장
|
||||||
if (thElement) {
|
if (thElement) {
|
||||||
const finalWidth = Math.max(80, thElement.offsetWidth);
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||||
setColumnWidths((prev) => ({ ...prev, [column.columnName]: finalWidth }));
|
setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 텍스트 선택 복원
|
// 텍스트 선택 복원
|
||||||
document.body.style.userSelect = "";
|
document.body.style.userSelect = '';
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isResizing.current = false;
|
isResizing.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1932,13 +1714,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
{/* 바디 (스크롤) */}
|
{/* 바디 (스크롤) */}
|
||||||
<tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" />
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
<div className="text-muted-foreground text-sm font-medium">로딩 중...</div>
|
<div className="text-sm font-medium text-muted-foreground">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1946,8 +1728,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="text-destructive text-sm font-medium">오류 발생</div>
|
<div className="text-sm font-medium text-destructive">오류 발생</div>
|
||||||
<div className="text-muted-foreground text-xs">{error}</div>
|
<div className="text-xs text-muted-foreground">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1955,9 +1737,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<TableIcon className="text-muted-foreground/50 h-12 w-12" />
|
<TableIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||||
<div className="text-muted-foreground text-sm font-medium">데이터가 없습니다</div>
|
<div className="text-sm font-medium text-muted-foreground">데이터가 없습니다</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-xs text-muted-foreground">
|
||||||
조건을 변경하거나 새로운 데이터를 추가해보세요
|
조건을 변경하거나 새로운 데이터를 추가해보세요
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1973,11 +1755,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={visibleColumns.length}
|
colSpan={visibleColumns.length}
|
||||||
className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b"
|
className="bg-muted border-b border-border sticky top-[48px] z-[5]"
|
||||||
style={{ top: "48px" }}
|
style={{ top: "48px" }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3"
|
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/80"
|
||||||
onClick={() => toggleGroupCollapse(group.groupKey)}
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
|
|
@ -1985,7 +1767,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 text-sm font-medium">{group.groupKey}</span>
|
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||||
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1996,7 +1778,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
|
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(row, index, e)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -2025,24 +1807,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign:
|
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||||
column.columnName === "__checkbox__"
|
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||||
? "center"
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
: isNumeric
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
? "right"
|
...(isFrozen && { left: `${leftPosition}px` })
|
||||||
: column.align || "left",
|
|
||||||
width:
|
|
||||||
column.columnName === "__checkbox__"
|
|
||||||
? "48px"
|
|
||||||
: `${100 / visibleColumns.length}%`,
|
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.columnName === "__checkbox__"
|
{column.columnName === "__checkbox__"
|
||||||
|
|
@ -2062,7 +1836,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-muted/50 h-10 cursor-pointer border-b transition-colors sm:h-12",
|
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(row, index, e)}
|
onClick={(e) => handleRowClick(row, index, e)}
|
||||||
>
|
>
|
||||||
|
|
@ -2091,21 +1865,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<td
|
<td
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 overflow-hidden text-xs text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign:
|
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||||
column.columnName === "__checkbox__"
|
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||||
? "center"
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
: isNumeric
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||||
? "right"
|
...(isFrozen && { left: `${leftPosition}px` })
|
||||||
: column.align || "left",
|
|
||||||
width: column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
|
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.columnName === "__checkbox__"
|
{column.columnName === "__checkbox__"
|
||||||
|
|
@ -2272,7 +2041,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<TableOptionsModal
|
<TableOptionsModal
|
||||||
isOpen={isTableOptionsOpen}
|
isOpen={isTableOptionsOpen}
|
||||||
onClose={() => setIsTableOptionsOpen(false)}
|
onClose={() => setIsTableOptionsOpen(false)}
|
||||||
columns={visibleColumns.map((col) => ({
|
columns={visibleColumns.map(col => ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||||
visible: col.visible !== false,
|
visible: col.visible !== false,
|
||||||
|
|
|
||||||
|
|
@ -718,13 +718,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
: componentConfig.placeholder || defaultPlaceholder
|
: componentConfig.placeholder || defaultPlaceholder
|
||||||
}
|
}
|
||||||
pattern={validationPattern}
|
pattern={validationPattern}
|
||||||
title={
|
title={webType === "tel" ? "전화번호 형식: 010-1234-5678" : undefined}
|
||||||
webType === "tel"
|
|
||||||
? "전화번호 형식: 010-1234-5678"
|
|
||||||
: component.label
|
|
||||||
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
|
||||||
: component.columnName || undefined
|
|
||||||
}
|
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
required={componentConfig.required || false}
|
required={componentConfig.required || false}
|
||||||
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
|
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
|
||||||
|
|
|
||||||
|
|
@ -1987,12 +1987,7 @@ export class ButtonActionExecutor {
|
||||||
*/
|
*/
|
||||||
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log("📤 엑셀 업로드 모달 열기:", {
|
console.log("📤 엑셀 업로드 모달 열기:", { config, context });
|
||||||
config,
|
|
||||||
context,
|
|
||||||
userId: context.userId,
|
|
||||||
tableName: context.tableName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 동적 import로 모달 컴포넌트 로드
|
// 동적 import로 모달 컴포넌트 로드
|
||||||
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
||||||
|
|
@ -2009,28 +2004,11 @@ export class ButtonActionExecutor {
|
||||||
document.body.removeChild(modalContainer);
|
document.body.removeChild(modalContainer);
|
||||||
};
|
};
|
||||||
|
|
||||||
// localStorage 디버깅
|
|
||||||
const modalId = `excel-upload-${context.tableName || ""}`;
|
|
||||||
const storageKey = `modal_size_${modalId}_${context.userId || "guest"}`;
|
|
||||||
console.log("🔍 엑셀 업로드 모달 localStorage 확인:", {
|
|
||||||
modalId,
|
|
||||||
userId: context.userId,
|
|
||||||
storageKey,
|
|
||||||
savedSize: localStorage.getItem(storageKey),
|
|
||||||
});
|
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(ExcelUploadModal, {
|
React.createElement(ExcelUploadModal, {
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open: boolean) => {
|
onOpenChange: (open: boolean) => {
|
||||||
if (!open) {
|
if (!open) closeModal();
|
||||||
// 모달 닫을 때 localStorage 확인
|
|
||||||
console.log("🔍 모달 닫을 때 localStorage:", {
|
|
||||||
storageKey,
|
|
||||||
savedSize: localStorage.getItem(storageKey),
|
|
||||||
});
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
tableName: context.tableName || "",
|
tableName: context.tableName || "",
|
||||||
uploadMode: config.excelUploadMode || "insert",
|
uploadMode: config.excelUploadMode || "insert",
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
|
||||||
* 컬럼명 기반 스마트 웹 타입 추론 규칙
|
* 컬럼명 기반 스마트 웹 타입 추론 규칙
|
||||||
*/
|
*/
|
||||||
export const COLUMN_NAME_TO_WEB_TYPE: Record<string, WebType> = {
|
export const COLUMN_NAME_TO_WEB_TYPE: Record<string, WebType> = {
|
||||||
// 이미지 관련
|
|
||||||
image: "image",
|
|
||||||
img: "image",
|
|
||||||
picture: "image",
|
|
||||||
photo: "image",
|
|
||||||
thumbnail: "image",
|
|
||||||
avatar: "image",
|
|
||||||
icon: "image",
|
|
||||||
logo: "image",
|
|
||||||
|
|
||||||
// 이메일 관련
|
// 이메일 관련
|
||||||
email: "email",
|
email: "email",
|
||||||
mail: "email",
|
mail: "email",
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,6 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||||
// 파일
|
// 파일
|
||||||
file: "file-upload",
|
file: "file-upload",
|
||||||
|
|
||||||
// 이미지
|
|
||||||
image: "image-widget",
|
|
||||||
img: "image-widget",
|
|
||||||
picture: "image-widget",
|
|
||||||
photo: "image-widget",
|
|
||||||
|
|
||||||
// 버튼
|
// 버튼
|
||||||
button: "button-primary",
|
button: "button-primary",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import { WebType } from "./unified-core";
|
import { WebType } from "./unified-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 9개 핵심 입력 타입
|
* 8개 핵심 입력 타입
|
||||||
*/
|
*/
|
||||||
export type BaseInputType =
|
export type BaseInputType =
|
||||||
| "text" // 텍스트
|
| "text" // 텍스트
|
||||||
|
|
@ -18,8 +18,7 @@ export type BaseInputType =
|
||||||
| "entity" // 엔티티
|
| "entity" // 엔티티
|
||||||
| "select" // 선택박스
|
| "select" // 선택박스
|
||||||
| "checkbox" // 체크박스
|
| "checkbox" // 체크박스
|
||||||
| "radio" // 라디오버튼
|
| "radio"; // 라디오버튼
|
||||||
| "image"; // 이미지
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세부 타입 옵션 인터페이스
|
* 세부 타입 옵션 인터페이스
|
||||||
|
|
@ -93,9 +92,6 @@ export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>
|
||||||
{ value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" },
|
{ value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" },
|
||||||
{ value: "radio-vertical", label: "세로 라디오", description: "세로 배치" },
|
{ value: "radio-vertical", label: "세로 라디오", description: "세로 배치" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// 이미지 → image
|
|
||||||
image: [{ value: "image", label: "이미지", description: "이미지 URL 표시" }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -140,9 +136,6 @@ export function getBaseInputType(webType: WebType): BaseInputType {
|
||||||
// entity
|
// entity
|
||||||
if (webType === "entity") return "entity";
|
if (webType === "entity") return "entity";
|
||||||
|
|
||||||
// image
|
|
||||||
if (webType === "image") return "image";
|
|
||||||
|
|
||||||
// 기본값: text
|
// 기본값: text
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +167,6 @@ export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: strin
|
||||||
{ value: "select", label: "선택박스", description: "드롭다운 선택" },
|
{ value: "select", label: "선택박스", description: "드롭다운 선택" },
|
||||||
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치" },
|
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치" },
|
||||||
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" },
|
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" },
|
||||||
{ value: "image", label: "이미지", description: "이미지 표시" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ export type InputType =
|
||||||
| "category" // 카테고리
|
| "category" // 카테고리
|
||||||
| "select" // 선택박스
|
| "select" // 선택박스
|
||||||
| "checkbox" // 체크박스
|
| "checkbox" // 체크박스
|
||||||
| "radio" // 라디오버튼
|
| "radio"; // 라디오버튼
|
||||||
| "image"; // 이미지
|
|
||||||
|
|
||||||
// 입력 타입 옵션 정의
|
// 입력 타입 옵션 정의
|
||||||
export interface InputTypeOption {
|
export interface InputTypeOption {
|
||||||
|
|
@ -98,13 +97,6 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
|
||||||
category: "selection",
|
category: "selection",
|
||||||
icon: "Circle",
|
icon: "Circle",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "image",
|
|
||||||
label: "이미지",
|
|
||||||
description: "이미지 표시",
|
|
||||||
category: "basic",
|
|
||||||
icon: "Image",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 카테고리별 입력 타입 그룹화
|
// 카테고리별 입력 타입 그룹화
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ export type WebType =
|
||||||
| "code" // 공통코드 참조
|
| "code" // 공통코드 참조
|
||||||
| "entity" // 엔티티 참조
|
| "entity" // 엔티티 참조
|
||||||
| "file" // 파일 업로드
|
| "file" // 파일 업로드
|
||||||
| "image" // 이미지 표시
|
|
||||||
| "button"; // 버튼 컴포넌트
|
| "button"; // 버튼 컴포넌트
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue