Compare commits

...

20 Commits

Author SHA1 Message Date
kjs 832e80cd7f 배지 표시 수정 2025-11-06 14:18:36 +09:00
kjs 2e674e13d0 fix: resizable-dialog 주석 처리된 객체 리터럴 파싱 에러 수정
- 여러 줄 객체 리터럴을 한 줄로 변경
- console.log 주석이 파싱 에러를 일으키는 문제 해결
- 빌드 에러 해결
2025-11-06 13:26:54 +09:00
kjs bc826e8e49 chore: resizable-dialog 디버깅 로그 모두 제거
- console.log 20개 주석 처리
- 콘솔 스팸 방지
- 불필요한 로그 제거로 성능 개선
2025-11-06 12:46:08 +09:00
kjs 4affe623a5 fix: 카테고리 매핑 로딩 타이밍 개선
- loading 의존성 제거 (불필요한 재로드 방지)
- columnMeta 길이 변화로 매핑 로드 트리거
- 매핑 로드 전후 상태 디버깅 로그 추가
- categoryMappings 빈 객체 문제 해결
2025-11-06 12:43:01 +09:00
kjs f53a818f2f fix: 카테고리 매핑 변경 시 강제 리렌더링 추가
- categoryMappingsKey 상태 추가로 매핑 변경 감지
- 매핑 업데이트 시 key 증가로 tbody 리렌더링 강제
- 간헐적으로 배지가 표시되지 않던 타이밍 이슈 해결
- 카테고리 배지 렌더링 디버깅 로그 추가
2025-11-06 12:39:56 +09:00
kjs b5a83bb0f3 docs: inputType 사용 가이드 추가
- webType은 레거시, inputType만 사용해야 함을 명시
- API 호출 및 캐시 처리 방법 설명
- 실제 적용 사례 및 마이그레이션 체크리스트 포함
- 디버깅 팁 및 주요 inputType 종류 문서화
2025-11-06 12:32:17 +09:00
kjs 85e1b532fa fix: 캐시에서 inputType 누락 문제 해결
- 캐시된 데이터 사용 시 inputType이 설정되지 않던 문제 수정
- cached.inputTypes를 올바르게 매핑하여 meta에 포함
- webType 체크 제거, inputType만 사용하도록 변경
- 화면 전환 후 캐시 사용 시에도 카테고리 타입 정상 인식
2025-11-06 12:28:39 +09:00
kjs 4cd08c3900 fix: webType도 체크하여 카테고리 컬럼 감지
- inputType과 webType 모두 'category'인 경우 처리
- columnMeta에 inputType이 없어도 webType으로 감지 가능
- material 컬럼 등 webType만 있는 경우도 정상 동작
2025-11-06 12:27:22 +09:00
kjs 70dc24f7a1 fix: columnMeta 로딩 완료 후 카테고리 매핑 로드
- columnMeta가 비어있을 때 로딩 대기 로그 출력
- columnMeta 준비 완료 후에만 카테고리 매핑 시도
- 카테고리 컬럼 없음 로그에 디버깅 정보 추가
- 화면 전환 시 columnMeta → 카테고리 매핑 순서 보장
2025-11-06 12:26:07 +09:00
kjs cd961a2162 fix: 화면 복귀 시 카테고리 매핑 갱신 보장
- loading 상태를 의존성으로 변경
- 데이터 로드 완료 시점(loading: false)에 매핑 갱신
- 화면 전환 후 복귀 시에도 최신 카테고리 데이터 반영
- 로딩 중에는 매핑 로드하지 않도록 가드 추가
2025-11-06 12:24:12 +09:00
kjs 95b341df79 fix: 데이터 변경 시 카테고리 매핑 자동 갱신
- useEffect 의존성을 refreshTrigger에서 data.length로 변경
- 데이터가 추가/삭제/변경될 때마다 자동으로 매핑 갱신
- 화면 전환 후 데이터 로드 완료 시점에 매핑도 함께 갱신
2025-11-06 12:22:24 +09:00
kjs 49935189b6 fix: 화면 전환 후 카테고리 매핑 갱신 문제 해결
- useEffect 의존성 배열에 refreshTrigger 추가
- 데이터 새로고침 시 카테고리 매핑도 자동 갱신
- 매핑 로드 시작/종료 로그 추가하여 디버깅 용이성 향상
2025-11-06 12:20:58 +09:00
kjs 939a8696c8 feat: TableListComponent에서 카테고리 값을 배지로 표시
- categoryMappings 타입을 색상 포함하도록 수정
- 카테고리 값 로드 시 color 필드 포함
- formatValue에서 카테고리를 Badge 컴포넌트로 렌더링
- 매핑 없을 시에도 기본 slate 색상의 배지로 표시
- 디버깅 로그 추가
2025-11-06 12:18:43 +09:00
hjlee 9f4e71fc68 Merge pull request 'lhj' (#188) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/188
2025-11-06 12:18:21 +09:00
kjs b526d8ea2c fix: 카테고리 배지 표시 개선 및 디버깅 로그 추가
- 매핑이 없어도 항상 배지로 표시
- 매핑 없을 시 코드값 그대로 + 기본 slate 색상 사용
- 카테고리 매핑 로드 과정 로그 추가
- 기존 데이터에 기본 색상 추가하는 마이그레이션 스크립트 생성
2025-11-06 12:15:47 +09:00
leeheejin 3f890cdbfa Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Conflicts:
;	frontend/components/admin/CreateTableModal.tsx
;	frontend/components/screen/CopyScreenModal.tsx
;	frontend/components/screen/MenuAssignmentModal.tsx
;	frontend/components/screen/ScreenList.tsx
;	frontend/components/screen/widgets/FlowWidget.tsx
;	frontend/lib/registry/components/table-list/TableListComponent.tsx
2025-11-06 12:14:07 +09:00
kjs 7581cd1582 feat: 테이블 리스트에서 카테고리 값을 배지로 표시
- 카테고리 타입 컬럼을 배지 형태로 렌더링
- 사용자가 설정한 색상 적용
- categoryMappings에 라벨과 색상 모두 저장
- 기본 색상: #3b82f6 (파란색)
- 텍스트 색상: 흰색으로 고정하여 가독성 확보
2025-11-06 12:12:19 +09:00
leeheejin 0839f7f603 리사이징, 체크박스,엔터치면 다음 칸으로 이동, 표수정, 컬럼에서 이미지 넣는거 등등 2025-11-06 12:11:49 +09:00
kjs 1d87b6c3ac feat: 카테고리 값에 배지 색상 설정 기능 추가
- 카테고리 값 추가/편집 다이얼로그에 색상 선택기 추가
- 18가지 기본 색상 팔레트 제공
- 선택한 색상의 실시간 배지 미리보기
- color 필드를 통해 DB에 저장
- 테이블 리스트에서 배지 형태로 표시할 준비 완료
2025-11-06 12:09:28 +09:00
kjs 4b2514d9da Merge pull request 'fix: 카테고리 타입 컬럼 라벨 표시 및 빌드 오류 수정' (#187) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/187
2025-11-06 12:04:19 +09:00
44 changed files with 2226 additions and 694 deletions

View File

@ -0,0 +1,279 @@
# 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. **기존 코드 마이그레이션** 시 체크리스트 활용

View File

@ -67,6 +67,12 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
description: "단일 선택",
category: "selection",
},
{
value: "image",
label: "이미지",
description: "이미지 표시",
category: "basic",
},
];
// 입력 타입 검증 함수

View File

@ -0,0 +1,48 @@
# 마이그레이션 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만 저장하는 것이 좋습니다.

View File

@ -0,0 +1,86 @@
/**
* 브라우저 콘솔에서 실행하는 마이그레이션 스크립트
*
* 사용 방법:
* 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 참고");
}
})();

View File

@ -7,13 +7,13 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -321,20 +321,20 @@ export function CreateTableModal({
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ResizableDialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
</DialogTitle>
<DialogDescription>
</ResizableDialogTitle>
<ResizableDialogDescription>
{isDuplicateMode
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
}
</DialogDescription>
</DialogHeader>
</ResizableDialogDescription>
</ResizableDialogHeader>
<div className="space-y-6">
{/* 테이블 기본 정보 */}
@ -452,7 +452,7 @@ export function CreateTableModal({
)}
</div>
<DialogFooter className="gap-2">
<ResizableDialogFooter className="gap-2">
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
@ -482,8 +482,8 @@ export function CreateTableModal({
isDuplicateMode ? "복제 생성" : "테이블 생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
);
}

View File

@ -6,7 +6,13 @@
"use client";
import { useState, useEffect } from "react";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogFooter
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -142,14 +148,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ResizableDialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
DDL
</DialogTitle>
</DialogHeader>
</ResizableDialogTitle>
</ResizableDialogHeader>
<Tabs defaultValue="logs" className="w-full">
<TabsList className="grid w-full grid-cols-2">
@ -401,7 +407,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</ResizableDialogContent>
</ResizableDialog>
);
}

View File

@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<ResizableDialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}

View File

@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<ResizableDialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}

View File

@ -1,7 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogFooter
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -120,14 +126,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ResizableDialog open={open} onOpenChange={onOpenChange}>
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
{tableName} -
</DialogTitle>
</DialogHeader>
</ResizableDialogTitle>
</ResizableDialogHeader>
{/* 필터 영역 */}
<div className="space-y-3 rounded-lg border p-4">
@ -255,7 +261,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</ResizableDialogContent>
</ResizableDialog>
);
}

View File

@ -2,12 +2,12 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@ -119,8 +119,10 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
<ResizableDialogContent className="sm:max-w-[500px]">
<ResizableDialogHeader>
<ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription>'{dashboardTitle}' .</ResizableDialogDescription>
<div className="flex items-center gap-2">
<ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription>'{dashboardTitle}' .</ResizableDialogDescription>
</div>
</ResizableDialogHeader>
<div className="space-y-4 py-4">

View File

@ -3,12 +3,12 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -67,8 +67,10 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
<ResizableDialogHeader>
<ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription> </ResizableDialogDescription>
<div className="flex items-center gap-2">
<ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription> </ResizableDialogDescription>
</div>
</ResizableDialogHeader>
<div className="space-y-4 py-4">

View File

@ -395,7 +395,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
maxWidth={1400}
maxHeight={900}
modalId={`excel-upload-${tableName}`}
userId={userId}
userId={userId || "guest"}
>
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
ResizableDialog,
ResizableDialogContent,
@ -12,6 +12,7 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
interface ScreenModalState {
isOpen: boolean;
@ -26,6 +27,8 @@ interface ScreenModalProps {
}
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId } = useAuth();
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
screenId: null,
@ -218,28 +221,88 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
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 (
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
<ResizableDialogContent
className={`${modalStyle.className} ${className || ""}`}
<ResizableDialogContent
className={`${modalStyle.className} ${className || ""}`}
style={modalStyle.style}
defaultWidth={800}
defaultHeight={600}
minWidth={600}
defaultWidth={600}
defaultHeight={800}
minWidth={500}
minHeight={400}
maxWidth={1400}
maxHeight={1000}
modalId={`screen-modal-${modalState.screenId}`}
maxWidth={1600}
maxHeight={1200}
modalId={persistedModalId}
userId={userId || "guest"}
>
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
{modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
)}
{loading && (
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
)}
<div className="flex items-center gap-2">
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
{modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs">
{modalState.description}
</ResizableDialogDescription>
)}
{loading && (
<ResizableDialogDescription className="text-xs">
{loading ? "화면을 불러오는 중입니다..." : ""}
</ResizableDialogDescription>
)}
</div>
</ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto">

View File

@ -168,20 +168,20 @@ export function TableOptionsModal({
</ResizableDialogDescription>
</ResizableDialogHeader>
<Tabs defaultValue="columns" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<Tabs defaultValue="columns" className="flex flex-col flex-1 overflow-hidden">
<TabsList className="grid w-full grid-cols-3 flex-shrink-0">
<TabsTrigger value="columns" 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>
</TabsList>
{/* 컬럼 설정 탭 */}
<TabsContent value="columns" className="space-y-3 sm:space-y-4 mt-4">
<TabsContent value="columns" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4">
<div className="text-xs sm:text-sm text-muted-foreground mb-2">
, / .
</div>
<div className="space-y-2 max-h-[400px] overflow-y-auto">
<div className="space-y-2">
{columns.map((column, index) => (
<div
key={column.columnName}
@ -249,7 +249,7 @@ export function TableOptionsModal({
</TabsContent>
{/* 표시 설정 탭 */}
<TabsContent value="display" className="space-y-3 sm:space-y-4 mt-4">
<TabsContent value="display" className="flex-1 overflow-y-auto 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="space-y-0.5">
<Label className="text-xs sm:text-sm font-medium"> </Label>
@ -265,7 +265,7 @@ export function TableOptionsModal({
</TabsContent>
{/* 보기 모드 탭 */}
<TabsContent value="view" className="space-y-3 sm:space-y-4 mt-4">
<TabsContent value="view" className="flex-1 overflow-y-auto space-y-3 sm:space-y-4 mt-4">
<div className="grid gap-3">
<Button
variant={viewMode === "table" ? "default" : "outline"}

View File

@ -2,13 +2,13 @@
import React, { useState, useEffect } from "react";
import {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -101,17 +101,17 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
};
return (
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
<ResizableDialogContent className="sm:max-w-[500px]">
<ResizableDialogHeader>
<ResizableDialogTitle className="flex items-center gap-2">
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
</ResizableDialogTitle>
<ResizableDialogDescription>
</DialogTitle>
<DialogDescription>
{sourceScreen?.screenName} . .
</ResizableDialogDescription>
</ResizableDialogHeader>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 원본 화면 정보 */}
@ -168,7 +168,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</div>
</div>
<ResizableDialogFooter>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button>
@ -185,8 +185,8 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</>
)}
</Button>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -305,17 +305,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
minHeight={400}
maxWidth={1400}
maxHeight={1000}
modalId={`edit-modal-${modalState.screenId}`}
modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined}
userId={user?.userId}
>
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
{modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
)}
{loading && (
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
)}
<div className="flex items-center gap-2">
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
{modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
)}
{loading && (
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
)}
</div>
</ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto">

View File

@ -144,8 +144,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
// 공통코드 옵션 가져오기
const loadCodeOptions = useCallback(
@ -208,7 +208,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
if (!categoryColumns || categoryColumns.length === 0) return;
// 각 카테고리 컬럼의 값 목록 조회
const mappings: Record<string, Record<string, string>> = {};
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const col of categoryColumns) {
try {
@ -217,18 +217,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
if (response.data.success && response.data.data) {
// valueCode -> valueLabel 매핑 생성
const mapping: Record<string, string> = {};
// valueCode -> {label, color} 매핑 생성
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
mapping[item.valueCode] = item.valueLabel;
mapping[item.valueCode] = {
label: item.valueLabel,
color: item.color,
};
});
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
}
} catch (error) {
// 카테고리 값 로드 실패 시 무시
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
}
}
console.log("📊 전체 카테고리 매핑:", mappings);
setCategoryMappings(mappings);
} catch (error) {
console.error("카테고리 매핑 로드 실패:", error);
@ -1911,13 +1916,27 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
switch (actualWebType) {
case "category": {
// 카테고리 타입: 코드값 -> 라벨로 변환
// 카테고리 타입: 배지로 표시
if (!value) return "";
const mapping = categoryMappings[column.columnName];
if (mapping && value) {
const label = mapping[String(value)];
return label || String(value);
}
return String(value || "");
const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
case "date":

View File

@ -1,9 +1,10 @@
"use client";
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog";
import { DialogTitle, DialogHeader } from "@/components/ui/dialog";
import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
@ -120,6 +121,67 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
[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 자동 입력 초기화
React.useEffect(() => {
const initAutoInputFields = async () => {
@ -630,11 +692,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
{/* 팝업 화면 렌더링 */}
{popupScreen && (
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<DialogContent
className={` ${
popupScreen.size === "small" ? "max-w-md" : popupScreen.size === "large" ? "max-w-6xl" : "max-w-4xl"
} max-h-[90vh] overflow-y-auto`}
<ResizableDialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<ResizableDialogContent
className="overflow-hidden p-0"
defaultWidth={popupScreen.size === "small" ? 600 : popupScreen.size === "large" ? 1400 : 1000}
defaultHeight={800}
minWidth={500}
minHeight={400}
maxWidth={1600}
maxHeight={1200}
modalId={`popup-screen-${popupScreen.screenId}`}
userId={user?.userId || "guest"}
>
<DialogHeader>
<DialogTitle>{popupScreen.title}</DialogTitle>
@ -668,8 +736,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
))}
</div>
)}
</DialogContent>
</Dialog>
</ResizableDialogContent>
</ResizableDialog>
)}
</>
);

View File

@ -4,10 +4,10 @@ import React, { useState, useEffect, useRef } from "react";
import {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
ResizableResizableDialogHeader,
ResizableResizableDialogTitle,
ResizableResizableDialogDescription,
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -115,7 +115,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
autoRedirectTimerRef.current = null;
}
}
// 컴포넌트 언마운트 시 타이머 정리
return () => {
if (autoRedirectTimerRef.current) {
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
</div>
</div>
<DialogFooter>
<ResizableDialogFooter>
<Button
onClick={() => {
// 타이머 정리
@ -394,7 +394,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
clearTimeout(autoRedirectTimerRef.current);
autoRedirectTimerRef.current = null;
}
// 화면 목록으로 이동
if (onBackToList) {
onBackToList();
@ -407,7 +407,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
<Monitor className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</ResizableDialogFooter>
</>
) : (
// 기본 할당 화면

View File

@ -833,21 +833,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 현재 화면의 테이블 컬럼 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || tableName,
columnName: 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,
webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type,
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
}));
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,
columnName: 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,
webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type,
widgetType,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
};
});
const tableInfo: TableInfo = {
tableName,
@ -2593,6 +2607,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
defaultWidth,
});
// 🔍 이미지 타입 드래그앤드롭 디버깅
// if (column.widgetType === "image") {
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
// columnName: column.columnName,
// widgetType: column.widgetType,
// componentId,
// column,
// });
// }
newComponent = {
id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용

View File

@ -25,13 +25,14 @@ import {
} from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
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 { ScreenDefinition } from "@/types/screen";
@ -456,7 +457,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
}`}
onClick={() => onDesignScreen(screen)}
>
<TableCell className="h-16 cursor-pointer px-6 py-3">
<TableCell className="h-16 px-6 py-3 cursor-pointer">
<div>
<div className="font-medium">{screen.screenName}</div>
{screen.description && (
@ -696,10 +697,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</TableHeader>
<TableBody>
{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">
<Checkbox
checked={selectedScreenIds.includes(screen.screenId)}
@ -1065,11 +1063,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</AlertDialog>
{/* 화면 편집 다이얼로그 */}
<ResizableDialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<ResizableDialogContent className="sm:max-w-[500px]">
<ResizableDialogHeader>
<ResizableDialogTitle> </ResizableDialogTitle>
</ResizableDialogHeader>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-screenName"> *</Label>
@ -1106,23 +1104,23 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</Select>
</div>
</div>
<ResizableDialogFooter>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
</Button>
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
</Button>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 화면 미리보기 다이얼로그 */}
<ResizableDialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<ResizableDialogContent className="h-[95vh] max-w-[95vw]">
<ResizableDialogHeader>
<ResizableDialogTitle> - {screenToPreview?.screenName}</ResizableDialogTitle>
</ResizableDialogHeader>
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="h-[95vh] max-w-[95vw]">
<DialogHeader>
<DialogTitle> - {screenToPreview?.screenName}</DialogTitle>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
{isLoadingPreview ? (
<div className="flex h-full items-center justify-center">
@ -1268,12 +1266,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
height: component.style?.height || `${component.size.height}px`,
zIndex: component.position.z || 1,
};
// 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크)
if (
(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 스타일:", {
id: component.id,
@ -1284,7 +1281,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
appliedStyle: style,
});
}
return style;
})()}
>
@ -1361,7 +1358,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div>
)}
</div>
<ResizableDialogFooter>
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
</Button>
@ -1369,9 +1366,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<Palette className="mr-2 h-4 w-4" />
</Button>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -157,7 +157,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
<div className="space-y-1">
<Label htmlFor="backgroundImage" className="text-xs font-medium">
(CSS)
</Label>
<Input
id="backgroundImage"
@ -168,6 +168,9 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
className="h-6 w-full px-2 py-0 text-xs"
style={{ fontSize: "12px" }}
/>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
</div>
</div>

View File

@ -318,10 +318,10 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
}).length;
return (
<div className={cn("space-y-2", className)}>
{/* 필터 그리드 - 적절한 너비로 조정 */}
<div className={cn(className)}>
{/* 필터 그리드 + 초기화 버튼 한 줄 */}
{effectiveFilters.length > 0 && (
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap items-center gap-3">
{effectiveFilters.map((filter: DataTableFilter) => {
// 필터 개수에 따라 적절한 너비 계산
const getFilterWidth = () => {
@ -338,17 +338,14 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
</div>
);
})}
</div>
)}
{/* 필터 상태 및 초기화 버튼 */}
{activeFiltersCount > 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" />
</Button>
{/* 필터 초기화 버튼 - 같은 줄에 배치 */}
{activeFiltersCount > 0 && (
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2 flex-shrink-0">
<X className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>

View File

@ -66,6 +66,28 @@ export function FlowWidget({
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
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 resetFlow = useFlowStepStore((state) => state.resetFlow);
@ -92,40 +114,6 @@ export function FlowWidget({
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
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 [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
@ -382,6 +370,12 @@ export function FlowWidget({
});
setFilteredData(filtered);
console.log("🔍 검색 실행:", {
totalRows: stepData.length,
filteredRows: filtered.length,
searchValues,
hasSearchValue,
});
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
// 선택된 스텝의 데이터를 다시 로드하는 함수
@ -465,6 +459,7 @@ export function FlowWidget({
// 프리뷰 모드에서는 샘플 데이터만 표시
if (isPreviewMode) {
console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시");
setFlowData({
id: flowId || 0,
flowName: flowName || "샘플 플로우",
@ -641,9 +636,16 @@ export function FlowWidget({
try {
// 컬럼 라벨 조회
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) {
setColumnLabels(labelsResponse.data);
} else {
console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse);
setColumnLabels({});
}
@ -675,61 +677,6 @@ 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) => {
// 프리뷰 모드에서는 행 선택 차단
@ -747,6 +694,13 @@ export function FlowWidget({
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
rowIndex,
newSelectedSize: newSelected.size,
selectedData,
selectedStepId,
hasCallback: !!onSelectedDataChange,
});
onSelectedDataChange?.(selectedData, selectedStepId);
};
@ -828,17 +782,17 @@ export function FlowWidget({
>
{/* 콘텐츠 */}
<div className="relative flex flex-col items-center justify-center gap-2 pb-5 sm:gap-2.5 sm:pb-6">
{/* 스텝 이름 */}
{/* 스텝 이름 */}
<h4
className={`text-base font-semibold leading-tight transition-colors duration-300 sm:text-lg lg:text-xl ${
selectedStepId === step.id ? "text-primary" : "text-foreground group-hover:text-primary/80"
}`}
>
{step.stepName}
</h4>
{step.stepName}
</h4>
{/* 데이터 건수 */}
{showStepCount && (
{/* 데이터 건수 */}
{showStepCount && (
<div
className={`flex items-center gap-1.5 transition-all duration-300 ${
selectedStepId === step.id
@ -850,8 +804,8 @@ export function FlowWidget({
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
</span>
<span className="text-xs font-normal sm:text-sm"></span>
</div>
)}
</div>
)}
</div>
{/* 하단 선 */}
@ -870,14 +824,14 @@ export function FlowWidget({
{displayMode === "horizontal" ? (
<div className="flex items-center gap-1">
<div className="h-0.5 w-6 bg-border sm:w-8" />
<svg
<svg
className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="h-0.5 w-6 bg-border sm:w-8" />
</div>
) : (
@ -889,8 +843,8 @@ export function FlowWidget({
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<div className="h-6 w-0.5 bg-border sm:h-8" />
</div>
)}
@ -956,7 +910,7 @@ export function FlowWidget({
</Badge>
)}
</Button>
<Button
<Button
variant="outline"
size="sm"
onClick={() => {
@ -974,11 +928,11 @@ export function FlowWidget({
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
{groupByColumns.length}
</Badge>
)}
</Button>
)}
</Button>
</div>
</div>
</div>
</div>
{/* 🆕 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
@ -1050,24 +1004,24 @@ export function FlowWidget({
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
}`}
>
{allowDataMove && (
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
{allowDataMove && (
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
<Checkbox
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
/>
</div>
)}
</div>
)}
<div className="space-y-1.5">
{stepDataColumns.map((col) => (
{stepDataColumns.map((col) => (
<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-foreground truncate">{formatValue(row[col], col)}</span>
</div>
))}
<span className="text-foreground truncate">{formatValue(row[col])}</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
@ -1127,21 +1081,21 @@ export function FlowWidget({
const dataRows = group.items.map((row, itemIndex) => {
const actualIndex = displayData.indexOf(row);
return (
<TableRow
<TableRow
key={`${group.groupKey}-${itemIndex}`}
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
>
{allowDataMove && (
>
{allowDataMove && (
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
<Checkbox
<Checkbox
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
/>
</TableCell>
)}
{stepDataColumns.map((col) => (
/>
</TableCell>
)}
{stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col], col)}
{formatValue(row[col])}
</TableCell>
))}
</TableRow>
@ -1171,10 +1125,10 @@ export function FlowWidget({
)}
{stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col], col)}
</TableCell>
))}
</TableRow>
{formatValue(row[col])}
</TableCell>
))}
</TableRow>
);
})
)}
@ -1192,7 +1146,7 @@ export function FlowWidget({
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
<div className="text-muted-foreground text-xs sm:text-sm">
{stepDataPage} / {totalStepDataPages} ( {stepData.length.toLocaleString("ko-KR")})
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs"> :</span>
<Select

View File

@ -0,0 +1,199 @@
"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";

View File

@ -11,6 +11,7 @@ import { TextareaWidget } from "./TextareaWidget";
import { CheckboxWidget } from "./CheckboxWidget";
import { RadioWidget } from "./RadioWidget";
import { FileWidget } from "./FileWidget";
import { ImageWidget } from "./ImageWidget";
import { CodeWidget } from "./CodeWidget";
import { EntityWidget } from "./EntityWidget";
import { RatingWidget } from "./RatingWidget";
@ -24,6 +25,7 @@ export { TextareaWidget } from "./TextareaWidget";
export { CheckboxWidget } from "./CheckboxWidget";
export { RadioWidget } from "./RadioWidget";
export { FileWidget } from "./FileWidget";
export { ImageWidget } from "./ImageWidget";
export { CodeWidget } from "./CodeWidget";
export { EntityWidget } from "./EntityWidget";
export { RatingWidget } from "./RatingWidget";
@ -47,6 +49,8 @@ export const getWidgetComponentByName = (componentName: string): React.Component
return RadioWidget;
case "FileWidget":
return FileWidget;
case "ImageWidget":
return ImageWidget;
case "CodeWidget":
return CodeWidget;
case "EntityWidget":
@ -105,6 +109,12 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp
case "attachment":
return FileWidget;
case "image":
case "img":
case "picture":
case "photo":
return ImageWidget;
case "code":
case "script":
return CodeWidget;
@ -155,6 +165,7 @@ export const WebTypeComponents: Record<string, React.ComponentType<WebTypeCompon
checkbox: CheckboxWidget,
radio: RadioWidget,
file: FileWidget,
image: ImageWidget,
code: CodeWidget,
entity: EntityWidget,
rating: RatingWidget,

View File

@ -11,9 +11,33 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
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 {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -26,6 +50,7 @@ export const CategoryValueAddDialog: React.FC<
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState("#3b82f6");
// 라벨에서 코드 자동 생성
const generateCode = (label: string): string => {
@ -59,13 +84,14 @@ export const CategoryValueAddDialog: React.FC<
valueCode,
valueLabel: valueLabel.trim(),
description: description.trim(),
color: "#3b82f6",
color: color,
isDefault: false,
});
// 초기화
setValueLabel("");
setDescription("");
setColor("#3b82f6");
};
return (
@ -81,23 +107,56 @@ export const CategoryValueAddDialog: React.FC<
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<Input
id="valueLabel"
placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus
/>
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
</Label>
<Input
id="valueLabel"
placeholder="예: 개발, 긴급, 진행중"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
autoFocus
/>
</div>
<Textarea
id="description"
placeholder="설명 (선택사항)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm"
rows={3}
/>
<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
id="description"
placeholder="설명을 입력하세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">

View File

@ -11,7 +11,9 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueEditDialogProps {
@ -22,15 +24,39 @@ interface CategoryValueEditDialogProps {
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<
CategoryValueEditDialogProps
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.valueLabel);
const [description, setDescription] = useState(value.description || "");
const [color, setColor] = useState(value.color || "#3b82f6");
useEffect(() => {
setValueLabel(value.valueLabel);
setDescription(value.description || "");
setColor(value.color || "#3b82f6");
}, [value]);
const handleSubmit = () => {
@ -41,6 +67,7 @@ export const CategoryValueEditDialog: React.FC<
onUpdate(value.valueId!, {
valueLabel: valueLabel.trim(),
description: description.trim(),
color: color,
});
};
@ -57,23 +84,56 @@ export const CategoryValueEditDialog: React.FC<
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<Input
id="valueLabel"
placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus
/>
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
</Label>
<Input
id="valueLabel"
placeholder="예: 개발, 긴급, 진행중"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm mt-1.5"
autoFocus
/>
</div>
<Textarea
id="description"
placeholder="설명 (선택사항)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm"
rows={3}
/>
<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
id="description"
placeholder="설명을 입력하세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm mt-1.5"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">

View File

@ -5,7 +5,23 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ResizableDialog = DialogPrimitive.Root;
// 🆕 Context를 사용하여 open 상태 공유
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;
@ -38,6 +54,7 @@ interface ResizableDialogContentProps
defaultHeight?: number;
modalId?: string; // localStorage 저장용 고유 ID
userId?: string; // 사용자별 저장용
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
}
const ResizableDialogContent = React.forwardRef<
@ -50,12 +67,13 @@ const ResizableDialogContent = React.forwardRef<
children,
minWidth = 400,
minHeight = 300,
maxWidth = 1400,
maxHeight = 900,
maxWidth = 1600,
maxHeight = 1200,
defaultWidth = 600,
defaultHeight = 500,
modalId,
userId = "guest",
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
style: userStyle,
...props
},
@ -69,6 +87,7 @@ const ResizableDialogContent = React.forwardRef<
if (!stableIdRef.current) {
if (modalId) {
stableIdRef.current = modalId;
// // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
} else {
// className 기반 ID 생성
if (className) {
@ -76,6 +95,7 @@ const ResizableDialogContent = React.forwardRef<
return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
// console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
} else if (userStyle) {
// userStyle 기반 ID 생성
const styleStr = JSON.stringify(userStyle);
@ -83,9 +103,11 @@ const ResizableDialogContent = React.forwardRef<
return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
// console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
} else {
// 기본 ID
stableIdRef.current = 'modal-default';
// console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
}
}
}
@ -132,39 +154,133 @@ const ResizableDialogContent = React.forwardRef<
const [isResizing, setIsResizing] = React.useState(false);
const [resizeDirection, setResizeDirection] = React.useState<string>("");
const [isInitialized, setIsInitialized] = React.useState(false);
// 모달이 열릴 때 초기 크기 설정 (localStorage 우선, 없으면 화면관리 설정)
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);
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) {
const initialSize = getInitialSize();
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
let attempts = 0;
const maxAttempts = 10;
// localStorage에서 저장된 크기가 있는지 확인
if (effectiveModalId && typeof window !== 'undefined') {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const saved = localStorage.getItem(storageKey);
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;
if (saved) {
const parsed = JSON.parse(saved);
// 저장된 크기가 있으면 그것을 사용 (사용자가 이전에 리사이즈한 크기)
const restoredSize = {
width: Math.max(minWidth, Math.min(maxWidth, parsed.width || initialSize.width)),
height: Math.max(minHeight, Math.min(maxHeight, parsed.height || initialSize.height)),
};
setSize(restoredSize);
setIsInitialized(true);
// 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;
}
} catch (error) {
console.error("모달 크기 복원 실패:", error);
}
}
// 패딩 추가 (p-6 * 2 = 48px)
const paddingAndMargin = 48;
const initialSize = getInitialSize();
// 내용 크기 기반 최소 크기 계산
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') {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const saved = localStorage.getItem(storageKey);
// console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
if (saved) {
const parsed = JSON.parse(saved);
// userResized 플래그 확인
if (parsed.userResized) {
const savedSize = {
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
};
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
// (사용자가 의도적으로 작게 만든 것을 존중)
finalSize = savedSize;
setUserResized(true);
// console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
} else {
// console.log(" 자동 계산된 크기는 무시, 내용 크기 사용");
}
} else {
// console.log(" localStorage에 저장된 크기 없음, 내용 크기 사용");
}
} catch (error) {
// console.error("❌ 모달 크기 복원 실패:", error);
}
}
setSize(finalSize);
setIsInitialized(true);
};
// 저장된 크기가 없으면 초기 크기 사용 (화면관리 설정 크기)
setSize(initialSize);
setIsInitialized(true);
// 첫 시도는 300ms 후에 시작
setTimeout(measureContent, 300);
}
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight]);
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
const startResize = (direction: string) => (e: React.MouseEvent) => {
e.preventDefault();
@ -206,14 +322,28 @@ const ResizableDialogContent = React.forwardRef<
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
// localStorage에 크기 저장 (리사이즈 후 새로고침해도 유지)
if (effectiveModalId && typeof window !== 'undefined') {
// 사용자가 리사이징했음을 표시
setUserResized(true);
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
try {
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));
// console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
} catch (error) {
console.error("모달 크기 저장 실패:", error);
// console.error("❌ 모달 크기 저장 실패:", error);
}
}
};
@ -243,7 +373,7 @@ const ResizableDialogContent = React.forwardRef<
minHeight: `${minHeight}px`,
}}
>
<div ref={contentRef} className="flex flex-col h-full overflow-hidden">
<div ref={contentRef} className="flex flex-col h-full overflow-auto">
{children}
</div>

View File

@ -20,17 +20,26 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
const { webTypes } = useWebTypes({ active: "Y" });
// 디버깅: 전달받은 웹타입과 props 정보 로깅
if (webType === "button") {
console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", {
webType,
component: props.component,
position: props.component?.position,
size: props.component?.size,
style: props.component?.style,
config,
});
}
// 디버깅: 이미지 타입만 로깅
// if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") {
// console.log(`🖼️ DynamicWebTypeRenderer 이미지 호출: webType="${webType}"`, {
// component: props.component,
// readonly: props.readonly,
// value: props.value,
// widgetType: props.component?.widgetType,
// });
// }
// 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(() => {
return WebTypeRegistry.getWebType(webType);
@ -64,23 +73,35 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
if (webType === "file" || props.component?.type === "file") {
try {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
// console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
} catch (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에서 지정된 컴포넌트 사용 (항상 우선)
if (dbWebType?.component_name) {
try {
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
console.log("DB 웹타입 정보:", dbWebType);
// console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
// console.log("DB 웹타입 정보:", dbWebType);
// FileWidget의 경우 FileUploadComponent 직접 사용
if (dbWebType.component_name === "FileWidget" || webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log("✅ FileWidget → FileUploadComponent 사용");
// console.log("✅ FileWidget → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />;
}
@ -88,7 +109,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
// return <ComponentByName {...props} {...finalProps} />;
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
// console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
// 로딩 중 메시지 대신 레지스트리로 폴백
// return <div>컴포넌트 로딩 중...</div>;
@ -99,18 +120,18 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 2순위: 레지스트리에 등록된 웹타입 사용
if (webTypeDefinition) {
console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
// console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
// console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />;
}
// 웹타입이 비활성화된 경우
if (!webTypeDefinition.isActive) {
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
// console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
return (
<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">
@ -138,28 +159,28 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
// console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />;
}
// 텍스트 입력 웹타입들
if (["text", "email", "password", "tel"].includes(webType)) {
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
// console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
return <TextInputComponent {...props} {...finalProps} />;
}
// 숫자 입력 웹타입들
if (["number", "decimal"].includes(webType)) {
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
// console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
return <NumberInputComponent {...props} {...finalProps} />;
}
// 날짜 입력 웹타입들
if (["date", "datetime", "time"].includes(webType)) {
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
// console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
return <DateInputComponent {...props} {...finalProps} />;
}
@ -173,7 +194,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 기본 폴백: Input 컴포넌트 사용
const { Input } = require("@/components/ui/input");
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
// console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
const safeFallbackProps = filterDOMProps(props);
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
} catch (error) {

View File

@ -35,12 +35,35 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
// 동적 웹타입 렌더링 사용
if (widgetType) {
try {
// 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거)
// 파일 위젯만 디자인 모드에서 인터랙션 허용
// 이미지 위젯은 실행 모드(모달)에서만 업로드 가능하도록 함
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 (
<div className="flex h-full flex-col">
<div className={isFileWidget ? "flex-1" : "pointer-events-none flex-1"}>
<div className={allowInteraction ? "flex-1" : "pointer-events-none flex-1"}>
<DynamicWebTypeRenderer
webType={widgetType}
props={{

View File

@ -0,0 +1,69 @@
"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;

View File

@ -0,0 +1,57 @@
"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();
}

View File

@ -0,0 +1,40 @@
"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";

View File

@ -28,6 +28,7 @@ import "./date-input/DateInputRenderer";
// import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
import "./text-display/TextDisplayRenderer";
import "./file-upload/FileUploadRenderer";
import "./image-widget/ImageWidgetRenderer";
import "./slider-basic/SliderBasicRenderer";
import "./toggle-switch/ToggleSwitchRenderer";
import "./image-display/ImageDisplayRenderer";

View File

@ -58,14 +58,14 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
return (
<div
className="relative h-full overflow-x-auto overflow-y-auto bg-background shadow-sm backdrop-blur-sm"
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm"
style={{
width: "100%",
maxWidth: "100%",
maxHeight: "100%", // 최대 높이 제한으로 스크롤 활성화
boxSizing: "border-box",
}}
>
<div className="relative flex-1 overflow-x-auto overflow-y-auto">
<Table
className="w-full"
style={{
@ -78,9 +78,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableHeader
className={
tableConfig.stickyHeader
? "sticky top-0 z-20 border-b bg-background backdrop-blur-sm"
: "border-b bg-background backdrop-blur-sm"
? "sticky top-0 border-b shadow-md"
: "border-b"
}
style={{
position: "sticky",
top: 0,
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
@ -103,15 +109,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "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",
? "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 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",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일
column.fixed === "left" &&
"sticky z-10 border-r border-border bg-background shadow-sm",
"sticky z-40 border-r border-border bg-background shadow-sm",
column.fixed === "right" &&
"sticky z-10 border-l border-border bg-background shadow-sm",
"sticky z-40 border-l border-border bg-background shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
@ -123,6 +129,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
@ -245,6 +252,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
)}
</TableBody>
</Table>
</div>
</div>
);
};

View File

@ -718,7 +718,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: componentConfig.placeholder || defaultPlaceholder
}
pattern={validationPattern}
title={webType === "tel" ? "전화번호 형식: 010-1234-5678" : undefined}
title={
webType === "tel"
? "전화번호 형식: 010-1234-5678"
: component.label
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
: component.columnName || undefined
}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}

View File

@ -1987,7 +1987,12 @@ export class ButtonActionExecutor {
*/
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📤 엑셀 업로드 모달 열기:", { config, context });
console.log("📤 엑셀 업로드 모달 열기:", {
config,
context,
userId: context.userId,
tableName: context.tableName,
});
// 동적 import로 모달 컴포넌트 로드
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
@ -2004,11 +2009,28 @@ export class ButtonActionExecutor {
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(
React.createElement(ExcelUploadModal, {
open: true,
onOpenChange: (open: boolean) => {
if (!open) closeModal();
if (!open) {
// 모달 닫을 때 localStorage 확인
console.log("🔍 모달 닫을 때 localStorage:", {
storageKey,
savedSize: localStorage.getItem(storageKey),
});
closeModal();
}
},
tableName: context.tableName || "",
uploadMode: config.excelUploadMode || "insert",

View File

@ -60,6 +60,16 @@ export const DB_TYPE_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",
mail: "email",

View File

@ -42,6 +42,12 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
// 파일
file: "file-upload",
// 이미지
image: "image-widget",
img: "image-widget",
picture: "image-widget",
photo: "image-widget",
// 버튼
button: "button-primary",

View File

@ -8,7 +8,7 @@
import { WebType } from "./unified-core";
/**
* 8
* 9
*/
export type BaseInputType =
| "text" // 텍스트
@ -18,7 +18,8 @@ export type BaseInputType =
| "entity" // 엔티티
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio"; // 라디오버튼
| "radio" // 라디오버튼
| "image"; // 이미지
/**
*
@ -92,6 +93,9 @@ export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>
{ value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" },
{ value: "radio-vertical", label: "세로 라디오", description: "세로 배치" },
],
// 이미지 → image
image: [{ value: "image", label: "이미지", description: "이미지 URL 표시" }],
};
/**
@ -136,6 +140,9 @@ export function getBaseInputType(webType: WebType): BaseInputType {
// entity
if (webType === "entity") return "entity";
// image
if (webType === "image") return "image";
// 기본값: text
return "text";
}
@ -167,6 +174,7 @@ export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: strin
{ value: "select", label: "선택박스", description: "드롭다운 선택" },
{ value: "checkbox", label: "체크박스", description: "체크박스/스위치" },
{ value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" },
{ value: "image", label: "이미지", description: "이미지 표시" },
];
/**

View File

@ -15,7 +15,8 @@ export type InputType =
| "category" // 카테고리
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio"; // 라디오버튼
| "radio" // 라디오버튼
| "image"; // 이미지
// 입력 타입 옵션 정의
export interface InputTypeOption {
@ -97,6 +98,13 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
category: "selection",
icon: "Circle",
},
{
value: "image",
label: "이미지",
description: "이미지 표시",
category: "basic",
icon: "Image",
},
];
// 카테고리별 입력 타입 그룹화

View File

@ -36,6 +36,7 @@ export type WebType =
| "code" // 공통코드 참조
| "entity" // 엔티티 참조
| "file" // 파일 업로드
| "image" // 이미지 표시
| "button"; // 버튼 컴포넌트
/**