fix: 카테고리 매핑 변경 시 강제 리렌더링 추가

- categoryMappingsKey 상태 추가로 매핑 변경 감지
- 매핑 업데이트 시 key 증가로 tbody 리렌더링 강제
- 간헐적으로 배지가 표시되지 않던 타이밍 이슈 해결
- 카테고리 배지 렌더링 디버깅 로그 추가
This commit is contained in:
kjs 2025-11-06 12:39:56 +09:00
commit f53a818f2f
39 changed files with 1708 additions and 653 deletions

View File

@ -58,17 +58,17 @@ inputTypes.forEach((col: any) => {
```typescript ```typescript
interface ColumnMeta { interface ColumnMeta {
webType?: string; // 레거시, 사용 금지 webType?: string; // 레거시, 사용 금지
codeCategory?: string; codeCategory?: string;
inputType?: string; // ✅ 반드시 이것 사용! inputType?: string; // ✅ 반드시 이것 사용!
} }
const columnMeta: Record<string, ColumnMeta> = { const columnMeta: Record<string, ColumnMeta> = {
material: { material: {
webType: "category", // 무시 webType: "category", // 무시
codeCategory: "", codeCategory: "",
inputType: "category" // ✅ 이것만 사용 inputType: "category", // ✅ 이것만 사용
} },
}; };
``` ```
@ -82,7 +82,7 @@ const columnMeta: Record<string, ColumnMeta> = {
const cached = tableColumnCache.get(cacheKey); const cached = tableColumnCache.get(cacheKey);
if (cached) { if (cached) {
const meta: Record<string, ColumnMeta> = {}; const meta: Record<string, ColumnMeta> = {};
cached.columns.forEach((col: any) => { cached.columns.forEach((col: any) => {
meta[col.columnName] = { meta[col.columnName] = {
webType: col.webType, webType: col.webType,
@ -99,7 +99,7 @@ if (cached) {
const cached = tableColumnCache.get(cacheKey); const cached = tableColumnCache.get(cacheKey);
if (cached) { if (cached) {
const meta: Record<string, ColumnMeta> = {}; const meta: Record<string, ColumnMeta> = {};
// 캐시된 inputTypes 맵 생성 // 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {}; const inputTypeMap: Record<string, string> = {};
if (cached.inputTypes) { if (cached.inputTypes) {
@ -107,7 +107,7 @@ if (cached) {
inputTypeMap[col.columnName] = col.inputType; inputTypeMap[col.columnName] = col.inputType;
}); });
} }
cached.columns.forEach((col: any) => { cached.columns.forEach((col: any) => {
meta[col.columnName] = { meta[col.columnName] = {
webType: col.webType, webType: col.webType,
@ -122,19 +122,19 @@ if (cached) {
## 주요 inputType 종류 ## 주요 inputType 종류
| inputType | 설명 | 사용 예시 | | inputType | 설명 | 사용 예시 |
|-----------|------|-----------| | ---------- | ---------------- | ------------------ |
| `text` | 일반 텍스트 입력 | 이름, 설명 등 | | `text` | 일반 텍스트 입력 | 이름, 설명 등 |
| `number` | 숫자 입력 | 금액, 수량 등 | | `number` | 숫자 입력 | 금액, 수량 등 |
| `date` | 날짜 입력 | 생성일, 수정일 등 | | `date` | 날짜 입력 | 생성일, 수정일 등 |
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 | | `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
| `category` | 카테고리 선택 | 분류, 상태 등 | | `category` | 카테고리 선택 | 분류, 상태 등 |
| `code` | 공통 코드 선택 | 코드 마스터 데이터 | | `code` | 공통 코드 선택 | 코드 마스터 데이터 |
| `boolean` | 예/아니오 | 활성화 여부 등 | | `boolean` | 예/아니오 | 활성화 여부 등 |
| `email` | 이메일 입력 | 이메일 주소 | | `email` | 이메일 입력 | 이메일 주소 |
| `url` | URL 입력 | 웹사이트 주소 | | `url` | URL 입력 | 웹사이트 주소 |
| `image` | 이미지 업로드 | 프로필 사진 등 | | `image` | 이미지 업로드 | 프로필 사진 등 |
| `file` | 파일 업로드 | 첨부파일 등 | | `file` | 파일 업로드 | 첨부파일 등 |
--- ---
@ -167,15 +167,15 @@ switch (inputType) {
case "category": case "category":
// 카테고리 배지 렌더링 // 카테고리 배지 렌더링
return <Badge>{categoryLabel}</Badge>; return <Badge>{categoryLabel}</Badge>;
case "code": case "code":
// 코드명 표시 // 코드명 표시
return codeName; return codeName;
case "date": case "date":
// 날짜 포맷팅 // 날짜 포맷팅
return formatDate(value); return formatDate(value);
default: default:
return value; return value;
} }
@ -187,20 +187,20 @@ switch (inputType) {
// ✅ inputType에 따라 다른 검색 UI 제공 // ✅ inputType에 따라 다른 검색 UI 제공
const renderSearchInput = (column: ColumnConfig) => { const renderSearchInput = (column: ColumnConfig) => {
const inputType = columnMeta[column.columnName]?.inputType; const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) { switch (inputType) {
case "category": case "category":
return <CategorySelect column={column} />; return <CategorySelect column={column} />;
case "code": case "code":
return <CodeSelect column={column} />; return <CodeSelect column={column} />;
case "date": case "date":
return <DateRangePicker column={column} />; return <DateRangePicker column={column} />;
case "number": case "number":
return <NumberRangeInput column={column} />; return <NumberRangeInput column={column} />;
default: default:
return <TextInput column={column} />; return <TextInput column={column} />;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { import {
ResizableDialog, ResizableDialog,
ResizableDialogContent, ResizableDialogContent,
@ -12,6 +12,7 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -26,6 +27,8 @@ interface ScreenModalProps {
} }
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => { export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId } = useAuth();
const [modalState, setModalState] = useState<ScreenModalState>({ const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false, isOpen: false,
screenId: null, screenId: null,
@ -218,28 +221,88 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}; };
const modalStyle = getModalStyle(); const modalStyle = getModalStyle();
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
// modalId 생성 및 업데이트
useEffect(() => {
// 모달이 열려있고 screenId가 있을 때만 업데이트
if (!modalState.isOpen) return;
let newModalId: string | undefined;
// 1순위: screenId (가장 안정적)
if (modalState.screenId) {
newModalId = `screen-modal-${modalState.screenId}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "screenId",
screenId: modalState.screenId,
result: newModalId,
});
}
// 2순위: 테이블명
else if (screenData?.screenInfo?.tableName) {
newModalId = `screen-modal-table-${screenData.screenInfo.tableName}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "tableName",
tableName: screenData.screenInfo.tableName,
result: newModalId,
});
}
// 3순위: 화면명
else if (screenData?.screenInfo?.screenName) {
newModalId = `screen-modal-name-${screenData.screenInfo.screenName}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "screenName",
screenName: screenData.screenInfo.screenName,
result: newModalId,
});
}
// 4순위: 제목
else if (modalState.title) {
const titleId = modalState.title.replace(/\s+/g, '-');
newModalId = `screen-modal-title-${titleId}`;
console.log("🔑 ScreenModal modalId 생성:", {
method: "title",
title: modalState.title,
result: newModalId,
});
}
if (newModalId) {
setPersistedModalId(newModalId);
}
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
return ( return (
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}> <ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
<ResizableDialogContent <ResizableDialogContent
className={`${modalStyle.className} ${className || ""}`} className={`${modalStyle.className} ${className || ""}`}
style={modalStyle.style} style={modalStyle.style}
defaultWidth={800} defaultWidth={600}
defaultHeight={600} defaultHeight={800}
minWidth={600} minWidth={500}
minHeight={400} minHeight={400}
maxWidth={1400} maxWidth={1600}
maxHeight={1000} maxHeight={1200}
modalId={`screen-modal-${modalState.screenId}`} modalId={persistedModalId}
userId={userId || "guest"}
> >
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3"> <ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle> <div className="flex items-center gap-2">
{modalState.description && !loading && ( <ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription> {modalState.description && !loading && (
)} <ResizableDialogDescription className="text-muted-foreground text-xs">
{loading && ( {modalState.description}
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription> </ResizableDialogDescription>
)} )}
{loading && (
<ResizableDialogDescription className="text-xs">
{loading ? "화면을 불러오는 중입니다..." : ""}
</ResizableDialogDescription>
)}
</div>
</ResizableDialogHeader> </ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto"> <div className="flex flex-1 items-center justify-center overflow-auto">

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useEffect } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, 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 { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file"; import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner"; import { toast } from "sonner";
@ -120,6 +121,67 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
[userName], [userName],
); );
// 🆕 Enter 키로 다음 필드 이동
useEffect(() => {
const handleEnterKey = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
const target = e.target as HTMLElement;
// textarea는 제외 (여러 줄 입력)
if (target.tagName === "TEXTAREA") {
return;
}
// input, select 등의 폼 요소에서만 작동
if (
target.tagName === "INPUT" ||
target.tagName === "SELECT" ||
target.getAttribute("role") === "combobox"
) {
e.preventDefault();
// 모든 포커스 가능한 요소 찾기
const focusableElements = document.querySelectorAll<HTMLElement>(
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])'
);
// 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬
const focusableArray = Array.from(focusableElements).sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
// Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로)
if (Math.abs(rectA.top - rectB.top) > 10) {
return rectA.top - rectB.top;
}
// 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로)
return rectA.left - rectB.left;
});
const currentIndex = focusableArray.indexOf(target);
if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) {
// 다음 요소로 포커스 이동
const nextElement = focusableArray[currentIndex + 1];
nextElement.focus();
// input이면 전체 선택
if (nextElement.tagName === "INPUT") {
(nextElement as HTMLInputElement).select();
}
}
}
}
};
document.addEventListener("keydown", handleEnterKey);
return () => {
document.removeEventListener("keydown", handleEnterKey);
};
}, []);
// 🆕 autoFill 자동 입력 초기화 // 🆕 autoFill 자동 입력 초기화
React.useEffect(() => { React.useEffect(() => {
const initAutoInputFields = async () => { const initAutoInputFields = async () => {
@ -630,11 +692,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}
{popupScreen && ( {popupScreen && (
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}> <ResizableDialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
<DialogContent <ResizableDialogContent
className={` ${ className="overflow-hidden p-0"
popupScreen.size === "small" ? "max-w-md" : popupScreen.size === "large" ? "max-w-6xl" : "max-w-4xl" defaultWidth={popupScreen.size === "small" ? 600 : popupScreen.size === "large" ? 1400 : 1000}
} max-h-[90vh] overflow-y-auto`} defaultHeight={800}
minWidth={500}
minHeight={400}
maxWidth={1600}
maxHeight={1200}
modalId={`popup-screen-${popupScreen.screenId}`}
userId={user?.userId || "guest"}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>{popupScreen.title}</DialogTitle> <DialogTitle>{popupScreen.title}</DialogTitle>
@ -668,8 +736,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
))} ))}
</div> </div>
)} )}
</DialogContent> </ResizableDialogContent>
</Dialog> </ResizableDialog>
)} )}
</> </>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -66,6 +66,28 @@ export function FlowWidget({
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기 const { user } = useAuth(); // 사용자 정보 가져오기
// 숫자 포맷팅 함수
const formatValue = (value: any): string => {
if (value === null || value === undefined || value === "") {
return "-";
}
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
if (typeof value === "number") {
return value.toLocaleString("ko-KR");
}
if (typeof value === "string") {
const numValue = parseFloat(value);
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
};
// 🆕 전역 상태 관리 // 🆕 전역 상태 관리
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
const resetFlow = useFlowStepStore((state) => state.resetFlow); const resetFlow = useFlowStepStore((state) => state.resetFlow);
@ -92,40 +114,6 @@ export function FlowWidget({
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록 const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터 const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, string>>>({});
// 값 포맷팅 함수 (숫자, 카테고리 등)
const formatValue = useCallback((value: any, columnName?: string): string => {
if (value === null || value === undefined || value === "") {
return "-";
}
// 카테고리 타입: 코드값 -> 라벨로 변환
if (columnName && categoryMappings[columnName]) {
const mapping = categoryMappings[columnName];
const label = mapping[String(value)];
if (label) {
return label;
}
}
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
if (typeof value === "number") {
return value.toLocaleString("ko-KR");
}
if (typeof value === "string") {
const numValue = parseFloat(value);
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
}, [categoryMappings]);
// 🆕 그룹 설정 관련 상태 // 🆕 그룹 설정 관련 상태
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록 const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
@ -382,6 +370,12 @@ export function FlowWidget({
}); });
setFilteredData(filtered); setFilteredData(filtered);
console.log("🔍 검색 실행:", {
totalRows: stepData.length,
filteredRows: filtered.length,
searchValues,
hasSearchValue,
});
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행 }, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
// 선택된 스텝의 데이터를 다시 로드하는 함수 // 선택된 스텝의 데이터를 다시 로드하는 함수
@ -465,6 +459,7 @@ export function FlowWidget({
// 프리뷰 모드에서는 샘플 데이터만 표시 // 프리뷰 모드에서는 샘플 데이터만 표시
if (isPreviewMode) { if (isPreviewMode) {
console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시");
setFlowData({ setFlowData({
id: flowId || 0, id: flowId || 0,
flowName: flowName || "샘플 플로우", flowName: flowName || "샘플 플로우",
@ -641,9 +636,16 @@ export function FlowWidget({
try { try {
// 컬럼 라벨 조회 // 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId!, stepId); const labelsResponse = await getStepColumnLabels(flowId!, stepId);
console.log("🏷️ 컬럼 라벨 조회 결과:", {
stepId,
success: labelsResponse.success,
labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0,
labels: labelsResponse.data,
});
if (labelsResponse.success && labelsResponse.data) { if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data); setColumnLabels(labelsResponse.data);
} else { } else {
console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse);
setColumnLabels({}); setColumnLabels({});
} }
@ -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) => { const toggleRowSelection = (rowIndex: number) => {
// 프리뷰 모드에서는 행 선택 차단 // 프리뷰 모드에서는 행 선택 차단
@ -747,6 +694,13 @@ export function FlowWidget({
// 선택된 데이터를 상위로 전달 // 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]); const selectedData = Array.from(newSelected).map((index) => stepData[index]);
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
rowIndex,
newSelectedSize: newSelected.size,
selectedData,
selectedStepId,
hasCallback: !!onSelectedDataChange,
});
onSelectedDataChange?.(selectedData, selectedStepId); onSelectedDataChange?.(selectedData, selectedStepId);
}; };
@ -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"> <div className="relative flex flex-col items-center justify-center gap-2 pb-5 sm:gap-2.5 sm:pb-6">
{/* 스텝 이름 */} {/* 스텝 이름 */}
<h4 <h4
className={`text-base font-semibold leading-tight transition-colors duration-300 sm:text-lg lg:text-xl ${ 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" selectedStepId === step.id ? "text-primary" : "text-foreground group-hover:text-primary/80"
}`} }`}
> >
{step.stepName} {step.stepName}
</h4> </h4>
{/* 데이터 건수 */} {/* 데이터 건수 */}
{showStepCount && ( {showStepCount && (
<div <div
className={`flex items-center gap-1.5 transition-all duration-300 ${ className={`flex items-center gap-1.5 transition-all duration-300 ${
selectedStepId === step.id selectedStepId === step.id
@ -850,8 +804,8 @@ export function FlowWidget({
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")} {(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
</span> </span>
<span className="text-xs font-normal sm:text-sm"></span> <span className="text-xs font-normal sm:text-sm"></span>
</div> </div>
)} )}
</div> </div>
{/* 하단 선 */} {/* 하단 선 */}
@ -870,14 +824,14 @@ export function FlowWidget({
{displayMode === "horizontal" ? ( {displayMode === "horizontal" ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="h-0.5 w-6 bg-border sm:w-8" /> <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" className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<div className="h-0.5 w-6 bg-border sm:w-8" /> <div className="h-0.5 w-6 bg-border sm:w-8" />
</div> </div>
) : ( ) : (
@ -889,8 +843,8 @@ export function FlowWidget({
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
<div className="h-6 w-0.5 bg-border sm:h-8" /> <div className="h-6 w-0.5 bg-border sm:h-8" />
</div> </div>
)} )}
@ -956,7 +910,7 @@ export function FlowWidget({
</Badge> </Badge>
)} )}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -974,11 +928,11 @@ export function FlowWidget({
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]"> <Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
{groupByColumns.length} {groupByColumns.length}
</Badge> </Badge>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/* 🆕 그룹 표시 배지 */} {/* 🆕 그룹 표시 배지 */}
{groupByColumns.length > 0 && ( {groupByColumns.length > 0 && (
@ -1050,24 +1004,24 @@ export function FlowWidget({
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : "" selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
}`} }`}
> >
{allowDataMove && ( {allowDataMove && (
<div className="mb-2 flex items-center justify-between border-b pb-2"> <div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span> <span className="text-muted-foreground text-xs font-medium"></span>
<Checkbox <Checkbox
checked={selectedRows.has(actualIndex)} checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)} onCheckedChange={() => toggleRowSelection(actualIndex)}
/> />
</div> </div>
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2 text-xs"> <div key={col} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span> <span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
<span className="text-foreground truncate">{formatValue(row[col], col)}</span> <span className="text-foreground truncate">{formatValue(row[col])}</span>
</div>
))}
</div> </div>
</div> ))}
</div>
</div>
); );
})} })}
</div> </div>
@ -1127,21 +1081,21 @@ export function FlowWidget({
const dataRows = group.items.map((row, itemIndex) => { const dataRows = group.items.map((row, itemIndex) => {
const actualIndex = displayData.indexOf(row); const actualIndex = displayData.indexOf(row);
return ( return (
<TableRow <TableRow
key={`${group.groupKey}-${itemIndex}`} key={`${group.groupKey}-${itemIndex}`}
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`} 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"> <TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
<Checkbox <Checkbox
checked={selectedRows.has(actualIndex)} checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)} onCheckedChange={() => toggleRowSelection(actualIndex)}
/> />
</TableCell> </TableCell>
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap"> <TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col], col)} {formatValue(row[col])}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
@ -1171,10 +1125,10 @@ export function FlowWidget({
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap"> <TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col], col)} {formatValue(row[col])}
</TableCell> </TableCell>
))} ))}
</TableRow> </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="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
<div className="text-muted-foreground text-xs sm:text-sm"> <div className="text-muted-foreground text-xs sm:text-sm">
{stepDataPage} / {totalStepDataPages} ( {stepData.length.toLocaleString("ko-KR")}) {stepDataPage} / {totalStepDataPages} ( {stepData.length.toLocaleString("ko-KR")})
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs"> :</span> <span className="text-muted-foreground text-xs"> :</span>
<Select <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 { CheckboxWidget } from "./CheckboxWidget";
import { RadioWidget } from "./RadioWidget"; import { RadioWidget } from "./RadioWidget";
import { FileWidget } from "./FileWidget"; import { FileWidget } from "./FileWidget";
import { ImageWidget } from "./ImageWidget";
import { CodeWidget } from "./CodeWidget"; import { CodeWidget } from "./CodeWidget";
import { EntityWidget } from "./EntityWidget"; import { EntityWidget } from "./EntityWidget";
import { RatingWidget } from "./RatingWidget"; import { RatingWidget } from "./RatingWidget";
@ -24,6 +25,7 @@ export { TextareaWidget } from "./TextareaWidget";
export { CheckboxWidget } from "./CheckboxWidget"; export { CheckboxWidget } from "./CheckboxWidget";
export { RadioWidget } from "./RadioWidget"; export { RadioWidget } from "./RadioWidget";
export { FileWidget } from "./FileWidget"; export { FileWidget } from "./FileWidget";
export { ImageWidget } from "./ImageWidget";
export { CodeWidget } from "./CodeWidget"; export { CodeWidget } from "./CodeWidget";
export { EntityWidget } from "./EntityWidget"; export { EntityWidget } from "./EntityWidget";
export { RatingWidget } from "./RatingWidget"; export { RatingWidget } from "./RatingWidget";
@ -47,6 +49,8 @@ export const getWidgetComponentByName = (componentName: string): React.Component
return RadioWidget; return RadioWidget;
case "FileWidget": case "FileWidget":
return FileWidget; return FileWidget;
case "ImageWidget":
return ImageWidget;
case "CodeWidget": case "CodeWidget":
return CodeWidget; return CodeWidget;
case "EntityWidget": case "EntityWidget":
@ -105,6 +109,12 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp
case "attachment": case "attachment":
return FileWidget; return FileWidget;
case "image":
case "img":
case "picture":
case "photo":
return ImageWidget;
case "code": case "code":
case "script": case "script":
return CodeWidget; return CodeWidget;
@ -155,6 +165,7 @@ export const WebTypeComponents: Record<string, React.ComponentType<WebTypeCompon
checkbox: CheckboxWidget, checkbox: CheckboxWidget,
radio: RadioWidget, radio: RadioWidget,
file: FileWidget, file: FileWidget,
image: ImageWidget,
code: CodeWidget, code: CodeWidget,
entity: EntityWidget, entity: EntityWidget,
rating: RatingWidget, rating: RatingWidget,

View File

@ -5,7 +5,23 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
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; const ResizableDialogTrigger = DialogPrimitive.Trigger;
@ -38,6 +54,7 @@ interface ResizableDialogContentProps
defaultHeight?: number; defaultHeight?: number;
modalId?: string; // localStorage 저장용 고유 ID modalId?: string; // localStorage 저장용 고유 ID
userId?: string; // 사용자별 저장용 userId?: string; // 사용자별 저장용
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
} }
const ResizableDialogContent = React.forwardRef< const ResizableDialogContent = React.forwardRef<
@ -50,12 +67,13 @@ const ResizableDialogContent = React.forwardRef<
children, children,
minWidth = 400, minWidth = 400,
minHeight = 300, minHeight = 300,
maxWidth = 1400, maxWidth = 1600,
maxHeight = 900, maxHeight = 1200,
defaultWidth = 600, defaultWidth = 600,
defaultHeight = 500, defaultHeight = 500,
modalId, modalId,
userId = "guest", userId = "guest",
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
style: userStyle, style: userStyle,
...props ...props
}, },
@ -69,6 +87,7 @@ const ResizableDialogContent = React.forwardRef<
if (!stableIdRef.current) { if (!stableIdRef.current) {
if (modalId) { if (modalId) {
stableIdRef.current = modalId; stableIdRef.current = modalId;
console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
} else { } else {
// className 기반 ID 생성 // className 기반 ID 생성
if (className) { if (className) {
@ -76,6 +95,10 @@ const ResizableDialogContent = React.forwardRef<
return ((acc << 5) - acc) + char.charCodeAt(0); return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0); }, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
console.log("🔄 ResizableDialog - className 기반 ID 생성:", {
className,
generatedId: stableIdRef.current,
});
} else if (userStyle) { } else if (userStyle) {
// userStyle 기반 ID 생성 // userStyle 기반 ID 생성
const styleStr = JSON.stringify(userStyle); const styleStr = JSON.stringify(userStyle);
@ -83,9 +106,14 @@ const ResizableDialogContent = React.forwardRef<
return ((acc << 5) - acc) + char.charCodeAt(0); return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0); }, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", {
userStyle,
generatedId: stableIdRef.current,
});
} else { } else {
// 기본 ID // 기본 ID
stableIdRef.current = 'modal-default'; stableIdRef.current = 'modal-default';
console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
} }
} }
} }
@ -132,39 +160,170 @@ const ResizableDialogContent = React.forwardRef<
const [isResizing, setIsResizing] = React.useState(false); const [isResizing, setIsResizing] = React.useState(false);
const [resizeDirection, setResizeDirection] = React.useState<string>(""); const [resizeDirection, setResizeDirection] = React.useState<string>("");
const [isInitialized, setIsInitialized] = React.useState(false); const [isInitialized, setIsInitialized] = React.useState(false);
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
// 모달이 열릴 때 초기 크기 설정 (localStorage 우선, 없으면 화면관리 설정) 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(() => { React.useEffect(() => {
console.log("🔍 모달 상태 변화 감지:", {
actualOpen,
wasOpen,
externalOpen,
contextOpen: context.open,
effectiveModalId
});
if (actualOpen && !wasOpen) {
// 모달이 방금 열림
console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
setIsInitialized(false);
setWasOpen(true);
} else if (!actualOpen && wasOpen) {
// 모달이 방금 닫힘
console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
setWasOpen(false);
}
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
React.useEffect(() => {
if (effectiveModalId !== lastModalId) {
console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", {
이전: lastModalId,
현재: effectiveModalId,
isInitialized,
});
setIsInitialized(false);
setUserResized(false); // 사용자 리사이징 플래그도 리셋
setLastModalId(effectiveModalId);
}
}, [effectiveModalId, lastModalId, isInitialized]);
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
React.useEffect(() => {
console.log("🔍 초기 크기 설정 useEffect 실행:", {
isInitialized,
hasContentRef: !!contentRef.current,
effectiveModalId,
});
if (!isInitialized) { if (!isInitialized) {
const initialSize = getInitialSize(); // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
let attempts = 0;
const maxAttempts = 10;
// localStorage에서 저장된 크기가 있는지 확인 const measureContent = () => {
if (effectiveModalId && typeof window !== 'undefined') { attempts++;
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`; // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
const saved = localStorage.getItem(storageKey); let contentWidth = defaultWidth;
let contentHeight = defaultHeight;
if (contentRef.current) {
// scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
contentWidth = contentRef.current.scrollWidth || defaultWidth;
contentHeight = contentRef.current.scrollHeight || defaultHeight;
if (saved) { console.log("📏 모달 내용 크기 측정:", {
const parsed = JSON.parse(saved); attempt: attempts,
// 저장된 크기가 있으면 그것을 사용 (사용자가 이전에 리사이즈한 크기) scrollWidth: contentRef.current.scrollWidth,
const restoredSize = { scrollHeight: contentRef.current.scrollHeight,
width: Math.max(minWidth, Math.min(maxWidth, parsed.width || initialSize.width)), clientWidth: contentRef.current.clientWidth,
height: Math.max(minHeight, Math.min(maxHeight, parsed.height || initialSize.height)), clientHeight: contentRef.current.clientHeight,
}; contentWidth,
setSize(restoredSize); contentHeight,
setIsInitialized(true); });
} else {
console.log("⚠️ contentRef 없음, 재시도:", {
attempt: attempts,
maxAttempts,
defaultWidth,
defaultHeight
});
// contentRef가 아직 없으면 재시도
if (attempts < maxAttempts) {
setTimeout(measureContent, 100);
return; 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);
};
// 저장된 크기가 없으면 초기 크기 사용 (화면관리 설정 크기) // 첫 시도는 300ms 후에 시작
setSize(initialSize); setTimeout(measureContent, 300);
setIsInitialized(true);
} }
}, [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) => { const startResize = (direction: string) => (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -206,14 +365,34 @@ const ResizableDialogContent = React.forwardRef<
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
// localStorage에 크기 저장 (리사이즈 후 새로고침해도 유지) // 사용자가 리사이징했음을 표시
if (effectiveModalId && typeof window !== 'undefined') { setUserResized(true);
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
try { try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`; const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const currentSize = { width: size.width, height: size.height };
// contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용
const modalElement = contentRef.current.parentElement;
const actualWidth = modalElement?.offsetWidth || size.width;
const actualHeight = modalElement?.offsetHeight || size.height;
const currentSize = {
width: actualWidth,
height: actualHeight,
userResized: true, // 사용자가 직접 리사이징했음을 표시
};
localStorage.setItem(storageKey, JSON.stringify(currentSize)); localStorage.setItem(storageKey, JSON.stringify(currentSize));
console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", {
effectiveModalId,
userId,
storageKey,
size: currentSize,
stateSize: { width: size.width, height: size.height },
});
} catch (error) { } catch (error) {
console.error("모달 크기 저장 실패:", error); console.error("모달 크기 저장 실패:", error);
} }
} }
}; };
@ -243,7 +422,7 @@ const ResizableDialogContent = React.forwardRef<
minHeight: `${minHeight}px`, 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} {children}
</div> </div>

View File

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

View File

@ -35,12 +35,35 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
// 동적 웹타입 렌더링 사용 // 동적 웹타입 렌더링 사용
if (widgetType) { if (widgetType) {
try { try {
// 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거) // 파일 위젯만 디자인 모드에서 인터랙션 허용
// 이미지 위젯은 실행 모드(모달)에서만 업로드 가능하도록 함
const isFileWidget = widgetType === "file"; const isFileWidget = widgetType === "file";
const isImageWidget = widgetType === "image" || widgetType === "img" || widgetType === "picture" || widgetType === "photo";
const allowInteraction = isFileWidget;
// 이미지 위젯은 래퍼 없이 직접 렌더링 (크기 문제 해결)
if (isImageWidget) {
return (
<div className="pointer-events-none h-full w-full">
<DynamicWebTypeRenderer
webType={widgetType}
props={{
...commonProps,
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
isDesignMode: true, // 디자인 모드임을 명시
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
}}
config={widget.webTypeConfig}
/>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className={isFileWidget ? "flex-1" : "pointer-events-none flex-1"}> <div className={allowInteraction ? "flex-1" : "pointer-events-none flex-1"}>
<DynamicWebTypeRenderer <DynamicWebTypeRenderer
webType={widgetType} webType={widgetType}
props={{ 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 "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체
import "./text-display/TextDisplayRenderer"; import "./text-display/TextDisplayRenderer";
import "./file-upload/FileUploadRenderer"; import "./file-upload/FileUploadRenderer";
import "./image-widget/ImageWidgetRenderer";
import "./slider-basic/SliderBasicRenderer"; import "./slider-basic/SliderBasicRenderer";
import "./toggle-switch/ToggleSwitchRenderer"; import "./toggle-switch/ToggleSwitchRenderer";
import "./image-display/ImageDisplayRenderer"; import "./image-display/ImageDisplayRenderer";

View File

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

View File

@ -718,7 +718,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: componentConfig.placeholder || defaultPlaceholder : componentConfig.placeholder || defaultPlaceholder
} }
pattern={validationPattern} 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} disabled={componentConfig.disabled || false}
required={componentConfig.required || false} required={componentConfig.required || false}
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")} readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}

View File

@ -1987,7 +1987,12 @@ export class ButtonActionExecutor {
*/ */
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> { private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try { try {
console.log("📤 엑셀 업로드 모달 열기:", { config, context }); console.log("📤 엑셀 업로드 모달 열기:", {
config,
context,
userId: context.userId,
tableName: context.tableName,
});
// 동적 import로 모달 컴포넌트 로드 // 동적 import로 모달 컴포넌트 로드
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
@ -2004,11 +2009,28 @@ export class ButtonActionExecutor {
document.body.removeChild(modalContainer); document.body.removeChild(modalContainer);
}; };
// localStorage 디버깅
const modalId = `excel-upload-${context.tableName || ""}`;
const storageKey = `modal_size_${modalId}_${context.userId || "guest"}`;
console.log("🔍 엑셀 업로드 모달 localStorage 확인:", {
modalId,
userId: context.userId,
storageKey,
savedSize: localStorage.getItem(storageKey),
});
root.render( root.render(
React.createElement(ExcelUploadModal, { React.createElement(ExcelUploadModal, {
open: true, open: true,
onOpenChange: (open: boolean) => { onOpenChange: (open: boolean) => {
if (!open) closeModal(); if (!open) {
// 모달 닫을 때 localStorage 확인
console.log("🔍 모달 닫을 때 localStorage:", {
storageKey,
savedSize: localStorage.getItem(storageKey),
});
closeModal();
}
}, },
tableName: context.tableName || "", tableName: context.tableName || "",
uploadMode: config.excelUploadMode || "insert", 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> = { export const COLUMN_NAME_TO_WEB_TYPE: Record<string, WebType> = {
// 이미지 관련
image: "image",
img: "image",
picture: "image",
photo: "image",
thumbnail: "image",
avatar: "image",
icon: "image",
logo: "image",
// 이메일 관련 // 이메일 관련
email: "email", email: "email",
mail: "email", mail: "email",

View File

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

View File

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

View File

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

View File

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