diff --git a/backend-node/src/types/input-types.ts b/backend-node/src/types/input-types.ts index 98e429c1..c3b3794b 100644 --- a/backend-node/src/types/input-types.ts +++ b/backend-node/src/types/input-types.ts @@ -67,6 +67,12 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [ description: "단일 선택", category: "selection", }, + { + value: "image", + label: "이미지", + description: "이미지 표시", + category: "basic", + }, ]; // 입력 타입 검증 함수 diff --git a/db/migrations/RUN_043_MIGRATION.md b/db/migrations/RUN_043_MIGRATION.md new file mode 100644 index 00000000..2159dace --- /dev/null +++ b/db/migrations/RUN_043_MIGRATION.md @@ -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만 저장하는 것이 좋습니다. + diff --git a/db/migrations/run_043_from_browser.js b/db/migrations/run_043_from_browser.js new file mode 100644 index 00000000..67a43505 --- /dev/null +++ b/db/migrations/run_043_from_browser.js @@ -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 참고"); + } +})(); + diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index b0e6a6e8..8de74c32 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -7,13 +7,13 @@ import { useState, useEffect } from "react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogDescription, + ResizableDialogFooter, +} from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -321,20 +321,20 @@ export function CreateTableModal({ const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType); return ( - - - - + + + + {isDuplicateMode ? "테이블 복제" : "새 테이블 생성"} - - + + {isDuplicateMode ? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.` : "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요." } - - + +
{/* 테이블 기본 정보 */} @@ -452,7 +452,7 @@ export function CreateTableModal({ )}
- + @@ -482,8 +482,8 @@ export function CreateTableModal({ isDuplicateMode ? "복제 생성" : "테이블 생성" )} - -
-
+ + + ); } diff --git a/frontend/components/admin/DDLLogViewer.tsx b/frontend/components/admin/DDLLogViewer.tsx index 36789547..d4441056 100644 --- a/frontend/components/admin/DDLLogViewer.tsx +++ b/frontend/components/admin/DDLLogViewer.tsx @@ -6,7 +6,13 @@ "use client"; import { useState, useEffect } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogFooter +} from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -142,14 +148,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) { }; return ( - - - - + + + + DDL 실행 로그 및 통계 - - + + @@ -401,7 +407,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) { )} - - + + ); } diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx index 66deaf39..181c6e4b 100644 --- a/frontend/components/admin/TableLogViewer.tsx +++ b/frontend/components/admin/TableLogViewer.tsx @@ -1,7 +1,13 @@ "use client"; import { useState, useEffect } from "react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogFooter +} from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -120,14 +126,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer }; return ( - - - - + + + + {tableName} - 변경 이력 - - + + {/* 필터 영역 */}
@@ -255,7 +261,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
-
-
+ + ); } diff --git a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx index c9c20a5f..5e8869a9 100644 --- a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx +++ b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx @@ -2,12 +2,12 @@ import React, { useState, useEffect } from "react"; import { - Dialog, - DialogContent, - - - DialogHeader, - + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogDescription, + ResizableDialogFooter, } from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -119,8 +119,10 @@ export const MenuAssignmentModal: React.FC = ({ - 대시보드 저장 완료 - '{dashboardTitle}' 대시보드가 저장되었습니다. +
+ 대시보드 저장 완료 + '{dashboardTitle}' 대시보드가 저장되었습니다. +
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx index 11d2e032..6fcaca8e 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx @@ -3,12 +3,12 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - - - DialogHeader, - + ResizableDialog, + ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogDescription, + ResizableDialogFooter, } from "@/components/ui/resizable-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -67,8 +67,10 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar e.stopPropagation()}> - 새 야드 생성 - 야드 이름을 입력하세요 +
+ 새 야드 생성 + 야드 이름을 입력하세요 +
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 94080df8..c5a25a65 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -395,7 +395,7 @@ export const ExcelUploadModal: React.FC = ({ maxWidth={1400} maxHeight={900} modalId={`excel-upload-${tableName}`} - userId={userId} + userId={userId || "guest"} > diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 68afc018..5703753a 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { ResizableDialog, ResizableDialogContent, @@ -12,6 +12,7 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; interface ScreenModalState { isOpen: boolean; @@ -26,6 +27,8 @@ interface ScreenModalProps { } export const ScreenModal: React.FC = ({ className }) => { + const { userId } = useAuth(); + const [modalState, setModalState] = useState({ isOpen: false, screenId: null, @@ -218,28 +221,88 @@ export const ScreenModal: React.FC = ({ className }) => { }; const modalStyle = getModalStyle(); + + // 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지) + const [persistedModalId, setPersistedModalId] = useState(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 ( - - {modalState.title} - {modalState.description && !loading && ( - {modalState.description} - )} - {loading && ( - {loading ? "화면을 불러오는 중입니다..." : ""} - )} +
+ {modalState.title} + {modalState.description && !loading && ( + + {modalState.description} + + )} + {loading && ( + + {loading ? "화면을 불러오는 중입니다..." : ""} + + )} +
diff --git a/frontend/components/common/TableOptionsModal.tsx b/frontend/components/common/TableOptionsModal.tsx index f5620c2a..f19a1a07 100644 --- a/frontend/components/common/TableOptionsModal.tsx +++ b/frontend/components/common/TableOptionsModal.tsx @@ -168,20 +168,20 @@ export function TableOptionsModal({ - - + + 컬럼 설정 표시 설정 보기 모드 {/* 컬럼 설정 탭 */} - +
드래그하여 순서를 변경하거나, 아이콘을 클릭하여 표시/숨기기를 설정하세요.
-
+
{columns.map((column, index) => (
{/* 표시 설정 탭 */} - +
@@ -265,7 +265,7 @@ export function TableOptionsModal({ {/* 보기 모드 탭 */} - +
- + @@ -185,8 +185,8 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS )} - - - + + + ); } diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 88681bb9..0e7cc4ec 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -305,17 +305,19 @@ export const EditModal: React.FC = ({ className }) => { minHeight={400} maxWidth={1400} maxHeight={1000} - modalId={`edit-modal-${modalState.screenId}`} + modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined} userId={user?.userId} > - {modalState.title || "데이터 수정"} - {modalState.description && !loading && ( - {modalState.description} - )} - {loading && ( - {loading ? "화면을 불러오는 중입니다..." : ""} - )} +
+ {modalState.title || "데이터 수정"} + {modalState.description && !loading && ( + {modalState.description} + )} + {loading && ( + {loading ? "화면을 불러오는 중입니다..." : ""} + )} +
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 93a823a4..c8e53cdd 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog"; +import { DialogTitle, DialogHeader } from "@/components/ui/dialog"; import { useAuth } from "@/hooks/useAuth"; import { uploadFilesAndCreateData } from "@/lib/api/file"; import { toast } from "sonner"; @@ -120,6 +121,67 @@ export const InteractiveScreenViewerDynamic: React.FC { + 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( + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])' + ); + + // 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬 + const focusableArray = Array.from(focusableElements).sort((a, b) => { + const rectA = a.getBoundingClientRect(); + const rectB = b.getBoundingClientRect(); + + // Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로) + if (Math.abs(rectA.top - rectB.top) > 10) { + return rectA.top - rectB.top; + } + + // 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로) + return rectA.left - rectB.left; + }); + + const currentIndex = focusableArray.indexOf(target); + + if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) { + // 다음 요소로 포커스 이동 + const nextElement = focusableArray[currentIndex + 1]; + nextElement.focus(); + + // input이면 전체 선택 + if (nextElement.tagName === "INPUT") { + (nextElement as HTMLInputElement).select(); + } + } + } + } + }; + + document.addEventListener("keydown", handleEnterKey); + + return () => { + document.removeEventListener("keydown", handleEnterKey); + }; + }, []); + // 🆕 autoFill 자동 입력 초기화 React.useEffect(() => { const initAutoInputFields = async () => { @@ -630,11 +692,17 @@ export const InteractiveScreenViewerDynamic: React.FC setPopupScreen(null)}> - setPopupScreen(null)}> + {popupScreen.title} @@ -668,8 +736,8 @@ export const InteractiveScreenViewerDynamic: React.FC )} - - + + )} ); diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index 92e48c6e..6fd586a8 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -4,10 +4,10 @@ import React, { useState, useEffect, useRef } from "react"; import { ResizableDialog, ResizableDialogContent, + ResizableDialogHeader, + ResizableDialogTitle, + ResizableDialogDescription, ResizableDialogFooter, - ResizableResizableDialogHeader, - ResizableResizableDialogTitle, - ResizableResizableDialogDescription, } from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -115,7 +115,7 @@ export const MenuAssignmentModal: React.FC = ({ autoRedirectTimerRef.current = null; } } - + // 컴포넌트 언마운트 시 타이머 정리 return () => { if (autoRedirectTimerRef.current) { @@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC = ({
- + - + ) : ( // 기본 할당 화면 diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index a7c3c2cf..3561fdec 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -833,21 +833,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 현재 화면의 테이블 컬럼 정보 조회 const columnsResponse = await tableTypeApi.getColumns(tableName); - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ - tableName: col.tableName || tableName, - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: col.inputType || col.input_type, - widgetType: col.widgetType || col.widget_type || col.webType || col.web_type, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - })); + const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { + const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type; + + // 🔍 이미지 타입 디버깅 + // if (widgetType === "image" || col.webType === "image" || col.web_type === "image") { + // console.log("🖼️ 이미지 컬럼 발견:", { + // columnName: col.columnName || col.column_name, + // widgetType, + // webType: col.webType || col.web_type, + // rawData: col, + // }); + // } + + return { + tableName: col.tableName || tableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + webType: col.webType || col.web_type, + input_type: col.inputType || col.input_type, + widgetType, + isNullable: col.isNullable || col.is_nullable, + required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", + columnDefault: col.columnDefault || col.column_default, + characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, + codeCategory: col.codeCategory || col.code_category, + codeValue: col.codeValue || col.code_value, + }; + }); const tableInfo: TableInfo = { tableName, @@ -2593,6 +2607,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD defaultWidth, }); + // 🔍 이미지 타입 드래그앤드롭 디버깅 + // if (column.widgetType === "image") { + // console.log("🖼️ 이미지 컬럼 드래그앤드롭:", { + // columnName: column.columnName, + // widgetType: column.widgetType, + // componentId, + // column, + // }); + // } + newComponent = { id: generateComponentId(), type: "component", // ✅ 새로운 컴포넌트 시스템 사용 diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 2f911818..98fb5b0f 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -25,13 +25,14 @@ import { } from "@/components/ui/alert-dialog"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; -import { - ResizableDialog, - ResizableDialogContent, - ResizableDialogHeader, - ResizableDialogTitle, - ResizableDialogFooter, -} from "@/components/ui/resizable-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; @@ -456,7 +457,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr }`} onClick={() => onDesignScreen(screen)} > - +
{screen.screenName}
{screen.description && ( @@ -696,10 +697,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr {deletedScreens.map((screen) => ( - + {/* 화면 편집 다이얼로그 */} - - - - 화면 정보 편집 - + + + + 화면 정보 편집 +
@@ -1106,23 +1104,23 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
- + - -
-
+ + + {/* 화면 미리보기 다이얼로그 */} - - - - 화면 미리보기 - {screenToPreview?.screenName} - + + + + 화면 미리보기 - {screenToPreview?.screenName} +
{isLoadingPreview ? (
@@ -1268,12 +1266,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr height: component.style?.height || `${component.size.height}px`, zIndex: component.position.z || 1, }; - + // 버튼 타입일 때 디버깅 (widget 타입 또는 component 타입 모두 체크) if ( (component.type === "widget" && (component as any).widgetType === "button") || - (component.type === "component" && - (component as any).componentType?.includes("button")) + (component.type === "component" && (component as any).componentType?.includes("button")) ) { console.log("🔘 ScreenList 버튼 외부 div 스타일:", { id: component.id, @@ -1284,7 +1281,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr appliedStyle: style, }); } - + return style; })()} > @@ -1361,7 +1358,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)}
- + @@ -1369,9 +1366,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr 편집 모드로 전환 - -
-
+ + +
); } diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index 95523901..054875f7 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -157,7 +157,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
+

+ 위젯 배경 꾸미기용 (고급 사용자 전용) +

diff --git a/frontend/components/screen/filters/AdvancedSearchFilters.tsx b/frontend/components/screen/filters/AdvancedSearchFilters.tsx index 79834508..fe582817 100644 --- a/frontend/components/screen/filters/AdvancedSearchFilters.tsx +++ b/frontend/components/screen/filters/AdvancedSearchFilters.tsx @@ -318,10 +318,10 @@ export const AdvancedSearchFilters: React.FC = ({ }).length; return ( -
- {/* 필터 그리드 - 적절한 너비로 조정 */} +
+ {/* 필터 그리드 + 초기화 버튼 한 줄 */} {effectiveFilters.length > 0 && ( -
+
{effectiveFilters.map((filter: DataTableFilter) => { // 필터 개수에 따라 적절한 너비 계산 const getFilterWidth = () => { @@ -338,17 +338,14 @@ export const AdvancedSearchFilters: React.FC = ({
); })} -
- )} - - {/* 필터 상태 및 초기화 버튼 */} - {activeFiltersCount > 0 && ( -
-
{activeFiltersCount}개 필터 적용 중
- + + {/* 필터 초기화 버튼 - 같은 줄에 배치 */} + {activeFiltersCount > 0 && ( + + )}
)}
diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index e5a2e541..a6bda4cb 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -66,6 +66,28 @@ export function FlowWidget({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { user } = useAuth(); // 사용자 정보 가져오기 + // 숫자 포맷팅 함수 + const formatValue = (value: any): string => { + if (value === null || value === undefined || value === "") { + return "-"; + } + + // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 + if (typeof value === "number") { + return value.toLocaleString("ko-KR"); + } + + if (typeof value === "string") { + const numValue = parseFloat(value); + // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 + if (!isNaN(numValue) && numValue.toString() === value.trim()) { + return numValue.toLocaleString("ko-KR"); + } + } + + return String(value); + }; + // 🆕 전역 상태 관리 const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const resetFlow = useFlowStepStore((state) => state.resetFlow); @@ -92,40 +114,6 @@ export function FlowWidget({ const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 - // 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> 라벨}) - const [categoryMappings, setCategoryMappings] = useState>>({}); - - // 값 포맷팅 함수 (숫자, 카테고리 등) - const formatValue = useCallback((value: any, columnName?: string): string => { - if (value === null || value === undefined || value === "") { - return "-"; - } - - // 카테고리 타입: 코드값 -> 라벨로 변환 - if (columnName && categoryMappings[columnName]) { - const mapping = categoryMappings[columnName]; - const label = mapping[String(value)]; - if (label) { - return label; - } - } - - // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 - if (typeof value === "number") { - return value.toLocaleString("ko-KR"); - } - - if (typeof value === "string") { - const numValue = parseFloat(value); - // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 - if (!isNaN(numValue) && numValue.toString() === value.trim()) { - return numValue.toLocaleString("ko-KR"); - } - } - - return String(value); - }, [categoryMappings]); - // 🆕 그룹 설정 관련 상태 const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 @@ -382,6 +370,12 @@ export function FlowWidget({ }); setFilteredData(filtered); + console.log("🔍 검색 실행:", { + totalRows: stepData.length, + filteredRows: filtered.length, + searchValues, + hasSearchValue, + }); }, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행 // 선택된 스텝의 데이터를 다시 로드하는 함수 @@ -465,6 +459,7 @@ export function FlowWidget({ // 프리뷰 모드에서는 샘플 데이터만 표시 if (isPreviewMode) { + console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시"); setFlowData({ id: flowId || 0, flowName: flowName || "샘플 플로우", @@ -641,9 +636,16 @@ export function FlowWidget({ try { // 컬럼 라벨 조회 const labelsResponse = await getStepColumnLabels(flowId!, stepId); + console.log("🏷️ 컬럼 라벨 조회 결과:", { + stepId, + success: labelsResponse.success, + labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0, + labels: labelsResponse.data, + }); if (labelsResponse.success && labelsResponse.data) { setColumnLabels(labelsResponse.data); } else { + console.warn("⚠️ 컬럼 라벨 조회 실패 또는 데이터 없음:", labelsResponse); setColumnLabels({}); } @@ -675,61 +677,6 @@ export function FlowWidget({ } }; - // 카테고리 타입 컬럼의 값 매핑 로드 - useEffect(() => { - const loadCategoryMappings = async () => { - if (!selectedStepId || !steps.length) return; - - try { - const currentStep = steps.find((s) => s.id === selectedStepId); - const tableName = currentStep?.stepConfig?.tableName; - - if (!tableName) return; - - // 테이블 컬럼 정보 조회하여 카테고리 타입 찾기 - const apiClient = (await import("@/lib/api/client")).apiClient; - const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); - - if (!columnsResponse.data?.success) return; - - const columns = columnsResponse.data.data?.columns || []; - const categoryColumns = columns.filter((col: any) => - (col.inputType === "category" || col.input_type === "category") - ); - - if (categoryColumns.length === 0) return; - - // 각 카테고리 컬럼의 값 목록 조회 - const mappings: Record> = {}; - - 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 = {}; - response.data.data.forEach((item: any) => { - mapping[item.valueCode] = item.valueLabel; - }); - mappings[columnName] = mapping; - } - } catch (error) { - // 카테고리 값 로드 실패 시 무시 - } - } - - setCategoryMappings(mappings); - } catch (error) { - console.error("FlowWidget 카테고리 매핑 로드 실패:", error); - } - }; - - loadCategoryMappings(); - }, [selectedStepId, steps]); - // 체크박스 토글 const toggleRowSelection = (rowIndex: number) => { // 프리뷰 모드에서는 행 선택 차단 @@ -747,6 +694,13 @@ export function FlowWidget({ // 선택된 데이터를 상위로 전달 const selectedData = Array.from(newSelected).map((index) => stepData[index]); + console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", { + rowIndex, + newSelectedSize: newSelected.size, + selectedData, + selectedStepId, + hasCallback: !!onSelectedDataChange, + }); onSelectedDataChange?.(selectedData, selectedStepId); }; @@ -828,17 +782,17 @@ export function FlowWidget({ > {/* 콘텐츠 */}
- {/* 스텝 이름 */} + {/* 스텝 이름 */}

- {step.stepName} -

+ {step.stepName} + - {/* 데이터 건수 */} - {showStepCount && ( + {/* 데이터 건수 */} + {showStepCount && (
-
- )} +
+ )}
{/* 하단 선 */} @@ -870,14 +824,14 @@ export function FlowWidget({ {displayMode === "horizontal" ? (
- - - + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + +
) : ( @@ -889,8 +843,8 @@ export function FlowWidget({ viewBox="0 0 24 24" stroke="currentColor" > - - + +
)} @@ -956,7 +910,7 @@ export function FlowWidget({ )} - + )} +
-
+
{/* 🆕 그룹 표시 배지 */} {groupByColumns.length > 0 && ( @@ -1050,24 +1004,24 @@ export function FlowWidget({ selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : "" }`} > - {allowDataMove && ( -
- 선택 + {allowDataMove && ( +
+ 선택 toggleRowSelection(actualIndex)} /> -
- )} +
+ )}
- {stepDataColumns.map((col) => ( + {stepDataColumns.map((col) => (
{columnLabels[col] || col}: - {formatValue(row[col], col)} -
- ))} + {formatValue(row[col])}
-
+ ))} +
+
); })} @@ -1127,21 +1081,21 @@ export function FlowWidget({ const dataRows = group.items.map((row, itemIndex) => { const actualIndex = displayData.indexOf(row); return ( - - {allowDataMove && ( + > + {allowDataMove && ( - toggleRowSelection(actualIndex)} - /> - - )} - {stepDataColumns.map((col) => ( + /> + + )} + {stepDataColumns.map((col) => ( - {formatValue(row[col], col)} + {formatValue(row[col])} ))} @@ -1171,10 +1125,10 @@ export function FlowWidget({ )} {stepDataColumns.map((col) => ( - {formatValue(row[col], col)} - - ))} - + {formatValue(row[col])} + + ))} + ); }) )} @@ -1192,7 +1146,7 @@ export function FlowWidget({
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건) -
+
표시 개수: + + {/* 필수 필드 경고 */} + {required && !imageUrl && ( +
* 이미지를 업로드해야 합니다
+ )} +
+ ); +}; + +ImageWidget.displayName = "ImageWidget"; diff --git a/frontend/components/screen/widgets/types/index.ts b/frontend/components/screen/widgets/types/index.ts index 00f1f338..4607619b 100644 --- a/frontend/components/screen/widgets/types/index.ts +++ b/frontend/components/screen/widgets/types/index.ts @@ -11,6 +11,7 @@ import { TextareaWidget } from "./TextareaWidget"; import { CheckboxWidget } from "./CheckboxWidget"; import { RadioWidget } from "./RadioWidget"; import { FileWidget } from "./FileWidget"; +import { ImageWidget } from "./ImageWidget"; import { CodeWidget } from "./CodeWidget"; import { EntityWidget } from "./EntityWidget"; import { RatingWidget } from "./RatingWidget"; @@ -24,6 +25,7 @@ export { TextareaWidget } from "./TextareaWidget"; export { CheckboxWidget } from "./CheckboxWidget"; export { RadioWidget } from "./RadioWidget"; export { FileWidget } from "./FileWidget"; +export { ImageWidget } from "./ImageWidget"; export { CodeWidget } from "./CodeWidget"; export { EntityWidget } from "./EntityWidget"; export { RatingWidget } from "./RatingWidget"; @@ -47,6 +49,8 @@ export const getWidgetComponentByName = (componentName: string): React.Component return RadioWidget; case "FileWidget": return FileWidget; + case "ImageWidget": + return ImageWidget; case "CodeWidget": return CodeWidget; case "EntityWidget": @@ -105,6 +109,12 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp case "attachment": return FileWidget; + case "image": + case "img": + case "picture": + case "photo": + return ImageWidget; + case "code": case "script": return CodeWidget; @@ -155,6 +165,7 @@ export const WebTypeComponents: Record({ open: false }); + +// 🆕 ResizableDialog를 래핑하여 Context 제공 +const ResizableDialog: React.FC> = ({ + children, + open = false, + ...props +}) => { + return ( + + + {children} + + + ); +}; const ResizableDialogTrigger = DialogPrimitive.Trigger; @@ -38,6 +54,7 @@ interface ResizableDialogContentProps defaultHeight?: number; modalId?: string; // localStorage 저장용 고유 ID userId?: string; // 사용자별 저장용 + open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달) } const ResizableDialogContent = React.forwardRef< @@ -50,12 +67,13 @@ const ResizableDialogContent = React.forwardRef< children, minWidth = 400, minHeight = 300, - maxWidth = 1400, - maxHeight = 900, + maxWidth = 1600, + maxHeight = 1200, defaultWidth = 600, defaultHeight = 500, modalId, userId = "guest", + open: externalOpen, // 🆕 외부에서 전달받은 open 상태 style: userStyle, ...props }, @@ -69,6 +87,7 @@ const ResizableDialogContent = React.forwardRef< if (!stableIdRef.current) { if (modalId) { stableIdRef.current = modalId; + console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId); } else { // className 기반 ID 생성 if (className) { @@ -76,6 +95,10 @@ const ResizableDialogContent = React.forwardRef< return ((acc << 5) - acc) + char.charCodeAt(0); }, 0); stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; + console.log("🔄 ResizableDialog - className 기반 ID 생성:", { + className, + generatedId: stableIdRef.current, + }); } else if (userStyle) { // userStyle 기반 ID 생성 const styleStr = JSON.stringify(userStyle); @@ -83,9 +106,14 @@ const ResizableDialogContent = React.forwardRef< return ((acc << 5) - acc) + char.charCodeAt(0); }, 0); stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`; + console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { + userStyle, + generatedId: stableIdRef.current, + }); } else { // 기본 ID stableIdRef.current = 'modal-default'; + console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)"); } } } @@ -132,39 +160,170 @@ const ResizableDialogContent = React.forwardRef< const [isResizing, setIsResizing] = React.useState(false); const [resizeDirection, setResizeDirection] = React.useState(""); const [isInitialized, setIsInitialized] = React.useState(false); - - // 모달이 열릴 때 초기 크기 설정 (localStorage 우선, 없으면 화면관리 설정) + const [lastModalId, setLastModalId] = React.useState(null); + const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적 + + // 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open) + const context = React.useContext(ResizableDialogContext); + const actualOpen = externalOpen !== undefined ? externalOpen : context.open; + + // 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋 + const [wasOpen, setWasOpen] = React.useState(false); + React.useEffect(() => { + console.log("🔍 모달 상태 변화 감지:", { + actualOpen, + wasOpen, + externalOpen, + contextOpen: context.open, + effectiveModalId + }); + + if (actualOpen && !wasOpen) { + // 모달이 방금 열림 + console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId }); + setIsInitialized(false); + setWasOpen(true); + } else if (!actualOpen && wasOpen) { + // 모달이 방금 닫힘 + console.log("🔒 모달 닫힘 감지:", { effectiveModalId }); + setWasOpen(false); + } + }, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]); + + // modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우) + React.useEffect(() => { + if (effectiveModalId !== lastModalId) { + console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { + 이전: lastModalId, + 현재: effectiveModalId, + isInitialized, + }); + setIsInitialized(false); + setUserResized(false); // 사용자 리사이징 플래그도 리셋 + setLastModalId(effectiveModalId); + } + }, [effectiveModalId, lastModalId, isInitialized]); + + // 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용) + React.useEffect(() => { + console.log("🔍 초기 크기 설정 useEffect 실행:", { + isInitialized, + hasContentRef: !!contentRef.current, + effectiveModalId, + }); + if (!isInitialized) { - const initialSize = getInitialSize(); + // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기) + // 여러 번 시도하여 contentRef가 준비될 때까지 대기 + let attempts = 0; + const maxAttempts = 10; - // localStorage에서 저장된 크기가 있는지 확인 - if (effectiveModalId && typeof window !== 'undefined') { - try { - const storageKey = `modal_size_${effectiveModalId}_${userId}`; - const saved = localStorage.getItem(storageKey); + const measureContent = () => { + attempts++; + + // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함) + let contentWidth = defaultWidth; + let contentHeight = defaultHeight; + + if (contentRef.current) { + // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거) + contentWidth = contentRef.current.scrollWidth || defaultWidth; + contentHeight = contentRef.current.scrollHeight || defaultHeight; - if (saved) { - const parsed = JSON.parse(saved); - // 저장된 크기가 있으면 그것을 사용 (사용자가 이전에 리사이즈한 크기) - const restoredSize = { - width: Math.max(minWidth, Math.min(maxWidth, parsed.width || initialSize.width)), - height: Math.max(minHeight, Math.min(maxHeight, parsed.height || initialSize.height)), - }; - setSize(restoredSize); - setIsInitialized(true); + console.log("📏 모달 내용 크기 측정:", { + attempt: attempts, + scrollWidth: contentRef.current.scrollWidth, + scrollHeight: contentRef.current.scrollHeight, + clientWidth: contentRef.current.clientWidth, + clientHeight: contentRef.current.clientHeight, + contentWidth, + contentHeight, + }); + } else { + console.log("⚠️ contentRef 없음, 재시도:", { + attempt: attempts, + maxAttempts, + defaultWidth, + defaultHeight + }); + + // contentRef가 아직 없으면 재시도 + if (attempts < maxAttempts) { + setTimeout(measureContent, 100); return; } - } catch (error) { - console.error("모달 크기 복원 실패:", error); } - } + + // 패딩 추가 (p-6 * 2 = 48px) + const paddingAndMargin = 48; + const initialSize = getInitialSize(); + + // 내용 크기 기반 최소 크기 계산 + const contentBasedSize = { + width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))), + height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))), + }; + + console.log("📐 내용 기반 크기:", contentBasedSize); + + // localStorage에서 저장된 크기 확인 + let finalSize = contentBasedSize; + + if (effectiveModalId && typeof window !== 'undefined') { + try { + const storageKey = `modal_size_${effectiveModalId}_${userId}`; + const saved = localStorage.getItem(storageKey); + + console.log("📦 localStorage 확인:", { + effectiveModalId, + userId, + storageKey, + saved: saved ? "있음" : "없음", + }); + + if (saved) { + const parsed = JSON.parse(saved); + + // userResized 플래그 확인 + if (parsed.userResized) { + const savedSize = { + width: Math.max(minWidth, Math.min(maxWidth, parsed.width)), + height: Math.max(minHeight, Math.min(maxHeight, parsed.height)), + }; + + console.log("💾 사용자가 리사이징한 크기 복원:", savedSize); + + // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용 + // (사용자가 의도적으로 작게 만든 것을 존중) + finalSize = savedSize; + setUserResized(true); + + console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { + savedSize, + contentBasedSize, + finalSize, + note: "사용자가 리사이징한 크기를 그대로 사용합니다", + }); + } else { + console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용"); + } + } else { + console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용"); + } + } catch (error) { + console.error("❌ 모달 크기 복원 실패:", error); + } + } + + setSize(finalSize); + setIsInitialized(true); + }; - // 저장된 크기가 없으면 초기 크기 사용 (화면관리 설정 크기) - setSize(initialSize); - setIsInitialized(true); + // 첫 시도는 300ms 후에 시작 + setTimeout(measureContent, 300); } - }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight]); + }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]); const startResize = (direction: string) => (e: React.MouseEvent) => { e.preventDefault(); @@ -206,14 +365,34 @@ const ResizableDialogContent = React.forwardRef< document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); - // localStorage에 크기 저장 (리사이즈 후 새로고침해도 유지) - if (effectiveModalId && typeof window !== 'undefined') { + // 사용자가 리사이징했음을 표시 + setUserResized(true); + + // ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기) + if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) { try { const storageKey = `modal_size_${effectiveModalId}_${userId}`; - const currentSize = { width: size.width, height: size.height }; + + // contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용 + const modalElement = contentRef.current.parentElement; + const actualWidth = modalElement?.offsetWidth || size.width; + const actualHeight = modalElement?.offsetHeight || size.height; + + const currentSize = { + width: actualWidth, + height: actualHeight, + userResized: true, // 사용자가 직접 리사이징했음을 표시 + }; localStorage.setItem(storageKey, JSON.stringify(currentSize)); + console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { + effectiveModalId, + userId, + storageKey, + size: currentSize, + stateSize: { width: size.width, height: size.height }, + }); } catch (error) { - console.error("모달 크기 저장 실패:", error); + console.error("❌ 모달 크기 저장 실패:", error); } } }; @@ -243,7 +422,7 @@ const ResizableDialogContent = React.forwardRef< minHeight: `${minHeight}px`, }} > -
+
{children}
diff --git a/frontend/lib/registry/DynamicWebTypeRenderer.tsx b/frontend/lib/registry/DynamicWebTypeRenderer.tsx index f6e7f8ef..9bfe59ff 100644 --- a/frontend/lib/registry/DynamicWebTypeRenderer.tsx +++ b/frontend/lib/registry/DynamicWebTypeRenderer.tsx @@ -20,17 +20,26 @@ export const DynamicWebTypeRenderer: React.FC = ({ // 모든 hooks를 먼저 호출 (조건부 return 이전에) const { webTypes } = useWebTypes({ active: "Y" }); - // 디버깅: 전달받은 웹타입과 props 정보 로깅 - if (webType === "button") { - console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", { - webType, - component: props.component, - position: props.component?.position, - size: props.component?.size, - style: props.component?.style, - config, - }); - } + // 디버깅: 이미지 타입만 로깅 + // if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") { + // console.log(`🖼️ DynamicWebTypeRenderer 이미지 호출: webType="${webType}"`, { + // component: props.component, + // readonly: props.readonly, + // value: props.value, + // widgetType: props.component?.widgetType, + // }); + // } + + // if (webType === "button") { + // console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", { + // webType, + // component: props.component, + // position: props.component?.position, + // size: props.component?.size, + // style: props.component?.style, + // config, + // }); + // } const webTypeDefinition = useMemo(() => { return WebTypeRegistry.getWebType(webType); @@ -64,23 +73,35 @@ export const DynamicWebTypeRenderer: React.FC = ({ if (webType === "file" || props.component?.type === "file") { try { const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); - console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`); + // console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`); return ; } catch (error) { console.error("FileUploadComponent 로드 실패:", error); } } + // 이미지 컴포넌트 강제 처리 + if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") { + try { + // console.log(`🎯 이미지 컴포넌트 감지! webType: ${webType}`, { props, finalProps }); + const { ImageWidget } = require("@/components/screen/widgets/types/ImageWidget"); + // console.log(`✅ ImageWidget 로드 성공`); + return ; + } catch (error) { + console.error("❌ ImageWidget 로드 실패:", error); + } + } + // 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선) if (dbWebType?.component_name) { try { - console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`); - console.log("DB 웹타입 정보:", dbWebType); + // console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`); + // console.log("DB 웹타입 정보:", dbWebType); // FileWidget의 경우 FileUploadComponent 직접 사용 if (dbWebType.component_name === "FileWidget" || webType === "file") { const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); - console.log("✅ FileWidget → FileUploadComponent 사용"); + // console.log("✅ FileWidget → FileUploadComponent 사용"); return ; } @@ -88,7 +109,7 @@ export const DynamicWebTypeRenderer: React.FC = ({ // const ComponentByName = getWidgetComponentByName(dbWebType.component_name); // console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName); // return ; - console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`); + // console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`); // 로딩 중 메시지 대신 레지스트리로 폴백 // return
컴포넌트 로딩 중...
; @@ -99,18 +120,18 @@ export const DynamicWebTypeRenderer: React.FC = ({ // 2순위: 레지스트리에 등록된 웹타입 사용 if (webTypeDefinition) { - console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`); + // console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`); // 파일 웹타입의 경우 FileUploadComponent 직접 사용 if (webType === "file") { const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); - console.log("✅ 파일 웹타입 → FileUploadComponent 사용"); + // console.log("✅ 파일 웹타입 → FileUploadComponent 사용"); return ; } // 웹타입이 비활성화된 경우 if (!webTypeDefinition.isActive) { - console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`); + // console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`); return (
@@ -138,28 +159,28 @@ export const DynamicWebTypeRenderer: React.FC = ({ // 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백) if (webType === "file") { const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); - console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용"); + // console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용"); return ; } // 텍스트 입력 웹타입들 if (["text", "email", "password", "tel"].includes(webType)) { const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent"); - console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`); + // console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`); return ; } // 숫자 입력 웹타입들 if (["number", "decimal"].includes(webType)) { const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent"); - console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`); + // console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`); return ; } // 날짜 입력 웹타입들 if (["date", "datetime", "time"].includes(webType)) { const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent"); - console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`); + // console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`); return ; } @@ -173,7 +194,7 @@ export const DynamicWebTypeRenderer: React.FC = ({ // 기본 폴백: Input 컴포넌트 사용 const { Input } = require("@/components/ui/input"); const { filterDOMProps } = require("@/lib/utils/domPropsFilter"); - console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`); + // console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`); const safeFallbackProps = filterDOMProps(props); return ; } catch (error) { diff --git a/frontend/lib/registry/components/WidgetRenderer.tsx b/frontend/lib/registry/components/WidgetRenderer.tsx index 395a8618..7b7aa218 100644 --- a/frontend/lib/registry/components/WidgetRenderer.tsx +++ b/frontend/lib/registry/components/WidgetRenderer.tsx @@ -35,12 +35,35 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => { // 동적 웹타입 렌더링 사용 if (widgetType) { try { - // 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거) + // 파일 위젯만 디자인 모드에서 인터랙션 허용 + // 이미지 위젯은 실행 모드(모달)에서만 업로드 가능하도록 함 const isFileWidget = widgetType === "file"; + const isImageWidget = widgetType === "image" || widgetType === "img" || widgetType === "picture" || widgetType === "photo"; + const allowInteraction = isFileWidget; + + // 이미지 위젯은 래퍼 없이 직접 렌더링 (크기 문제 해결) + if (isImageWidget) { + return ( +
+ +
+ ); + } return (
-
+
void; +} + +/** + * 이미지 위젯 설정 패널 + */ +export function ImageWidgetConfigPanel({ config, onConfigChange }: ImageWidgetConfigPanelProps) { + const handleChange = (key: string, value: any) => { + onConfigChange({ + ...config, + [key]: value, + }); + }; + + return ( + + + 이미지 설정 + 이미지 업로드 및 표시 설정 + + +
+ + handleChange("maxSize", parseInt(e.target.value) * 1024 * 1024)} + className="h-8 text-xs" + /> +
+ +
+ + handleChange("placeholder", e.target.value)} + className="h-8 text-xs" + /> +
+ +
+

지원 형식:

+

JPG, PNG, GIF, WebP

+
+
+
+ ); +} + +export default ImageWidgetConfigPanel; + diff --git a/frontend/lib/registry/components/image-widget/ImageWidgetRenderer.tsx b/frontend/lib/registry/components/image-widget/ImageWidgetRenderer.tsx new file mode 100644 index 00000000..6a45ab37 --- /dev/null +++ b/frontend/lib/registry/components/image-widget/ImageWidgetRenderer.tsx @@ -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 ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 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(); +} + diff --git a/frontend/lib/registry/components/image-widget/index.ts b/frontend/lib/registry/components/image-widget/index.ts new file mode 100644 index 00000000..67abbc80 --- /dev/null +++ b/frontend/lib/registry/components/image-widget/index.ts @@ -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"; + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index b053d136..f2385b9b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -28,6 +28,7 @@ import "./date-input/DateInputRenderer"; // import "./label-basic/LabelBasicRenderer"; // 제거됨 - text-display로 교체 import "./text-display/TextDisplayRenderer"; import "./file-upload/FileUploadRenderer"; +import "./image-widget/ImageWidgetRenderer"; import "./slider-basic/SliderBasicRenderer"; import "./toggle-switch/ToggleSwitchRenderer"; import "./image-display/ImageDisplayRenderer"; diff --git a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx index b7abeb7f..edfb4b7f 100644 --- a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx @@ -58,14 +58,14 @@ export const SingleTableWithSticky: React.FC = ({ return (
+
= ({ {actualColumns.map((column, colIndex) => { @@ -103,15 +109,15 @@ export const SingleTableWithSticky: React.FC = ({ key={column.columnName} className={cn( column.columnName === "__checkbox__" - ? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3" - : "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm", + ? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background" + : "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background", `text-${column.align}`, column.sortable && "hover:bg-primary/10", // 고정 컬럼 스타일 column.fixed === "left" && - "sticky z-10 border-r border-border bg-background shadow-sm", + "sticky z-40 border-r border-border bg-background shadow-sm", column.fixed === "right" && - "sticky z-10 border-l border-border bg-background shadow-sm", + "sticky z-40 border-l border-border bg-background shadow-sm", // 숨김 컬럼 스타일 (디자인 모드에서만) isDesignMode && column.hidden && "bg-muted/50 opacity-40", )} @@ -123,6 +129,7 @@ export const SingleTableWithSticky: React.FC = ({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 + backgroundColor: "hsl(var(--background))", // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), @@ -245,6 +252,7 @@ export const SingleTableWithSticky: React.FC = ({ )}
+
); }; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index f88ebba1..eec71f0a 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -7,6 +7,7 @@ import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; +import { getFullImageUrl } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; import { ChevronLeft, @@ -186,7 +187,9 @@ export const TableListComponent: React.FC = ({ // 객체인 경우 tableName 속성 추출 시도 if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { + console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable); finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; + console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable); } tableConfig.selectedTable = finalSelectedTable; @@ -242,8 +245,7 @@ export const TableListComponent: React.FC = ({ const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); const [displayColumns, setDisplayColumns] = useState([]); const [joinColumnMapping, setJoinColumnMapping] = useState>({}); - const [columnMeta, setColumnMeta] = useState>({}); - const [categoryMappings, setCategoryMappings] = useState>>({}); + const [columnMeta, setColumnMeta] = useState>({}); const [searchValues, setSearchValues] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); @@ -280,10 +282,13 @@ export const TableListComponent: React.FC = ({ if (savedOrder) { try { const parsedOrder = JSON.parse(savedOrder); + console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder }); setColumnOrder(parsedOrder); // 부모 컴포넌트에 초기 컬럼 순서 전달 if (onSelectedRowsChange && parsedOrder.length > 0) { + console.log("✅ 초기 컬럼 순서 전달:", parsedOrder); + // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) const initialData = data.map((row: any) => { const reordered: any = {}; @@ -301,6 +306,8 @@ export const TableListComponent: React.FC = ({ return reordered; }); + console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] }); + // 전역 저장소에 데이터 저장 if (tableConfig.selectedTable) { tableDisplayStore.setTableData( @@ -422,51 +429,6 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.selectedTable]); - // ======================================== - // 카테고리 값 매핑 로드 - // ======================================== - - useEffect(() => { - const loadCategoryMappings = async () => { - if (!tableConfig.selectedTable || !columnMeta) return; - - try { - const categoryColumns = Object.entries(columnMeta) - .filter(([_, meta]) => meta.inputType === "category") - .map(([columnName, _]) => columnName); - - if (categoryColumns.length === 0) return; - - const mappings: Record> = {}; - - for (const columnName of categoryColumns) { - try { - const apiClient = (await import("@/lib/api/client")).apiClient; - const response = await apiClient.get( - `/table-categories/${tableConfig.selectedTable}/${columnName}/values` - ); - - if (response.data.success && response.data.data) { - const mapping: Record = {}; - response.data.data.forEach((item: any) => { - mapping[item.valueCode] = item.valueLabel; - }); - mappings[columnName] = mapping; - } - } catch (error) { - // 카테고리 값 로드 실패 시 무시 - } - } - - setCategoryMappings(mappings); - } catch (error) { - console.error("TableListComponent 카테고리 매핑 로드 실패:", error); - } - }; - - loadCategoryMappings(); - }, [tableConfig.selectedTable, columnMeta]); - // ======================================== // 데이터 가져오기 // ======================================== @@ -577,6 +539,8 @@ export const TableListComponent: React.FC = ({ }; const handleSort = (column: string) => { + console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection }); + let newSortColumn = column; let newSortDirection: "asc" | "desc" = "asc"; @@ -590,6 +554,9 @@ export const TableListComponent: React.FC = ({ newSortDirection = "asc"; } + console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection }); + console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange); + // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 if (onSelectedRowsChange) { const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); @@ -639,6 +606,16 @@ export const TableListComponent: React.FC = ({ return reordered; }); + console.log("✅ 정렬 정보 전달:", { + selectedRowsCount: selectedRows.size, + selectedRowsDataCount: selectedRowsData.length, + sortBy: newSortColumn, + sortOrder: newSortDirection, + columnOrder: columnOrder.length > 0 ? columnOrder : undefined, + tableDisplayDataCount: reorderedData.length, + firstRowAfterSort: reorderedData[0]?.[newSortColumn], + lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn] + }); onSelectedRowsChange( Array.from(selectedRows), selectedRowsData, @@ -659,6 +636,8 @@ export const TableListComponent: React.FC = ({ newSortDirection ); } + } else { + console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); } }; @@ -671,11 +650,16 @@ export const TableListComponent: React.FC = ({ fetchTableDataDebounced(); }; - const handleClearAdvancedFilters = () => { + const handleClearAdvancedFilters = useCallback(() => { + console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues }); + + // 상태를 초기화하고 useEffect로 데이터 새로고침 setSearchValues({}); setCurrentPage(1); - fetchTableDataDebounced(); - }; + + // 강제로 데이터 새로고침 트리거 + setRefreshTrigger((prev) => prev + 1); + }, [searchValues]); const handleRefresh = () => { fetchTableDataDebounced(); @@ -750,6 +734,8 @@ export const TableListComponent: React.FC = ({ const isCurrentlySelected = selectedRows.has(rowKey); handleRowSelection(rowKey, !isCurrentlySelected); + + console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) @@ -794,6 +780,12 @@ export const TableListComponent: React.FC = ({ // columnOrder에 없는 새로운 컬럼들 추가 const remainingCols = cols.filter(c => !columnOrder.includes(c.columnName)); + console.log("🔄 columnOrder 기반 정렬:", { + columnOrder, + orderedColsCount: orderedCols.length, + remainingColsCount: remainingCols.length + }); + return [...orderedCols, ...remainingCols]; } @@ -804,11 +796,19 @@ export const TableListComponent: React.FC = ({ const lastColumnOrderRef = useRef(""); useEffect(() => { + console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", { + hasCallback: !!onSelectedRowsChange, + visibleColumnsLength: visibleColumns.length, + visibleColumnsNames: visibleColumns.map(c => c.columnName), + }); + if (!onSelectedRowsChange) { + console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); return; } if (visibleColumns.length === 0) { + console.warn("⚠️ visibleColumns가 비어있습니다!"); return; } @@ -816,14 +816,23 @@ export const TableListComponent: React.FC = ({ .map(col => col.columnName) .filter(name => name !== "__checkbox__"); // 체크박스 컬럼 제외 + console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); + // 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지) const columnOrderString = currentColumnOrder.join(","); + console.log("🔍 [컬럼 순서] 비교:", { + current: columnOrderString, + last: lastColumnOrderRef.current, + isDifferent: columnOrderString !== lastColumnOrderRef.current, + }); if (columnOrderString === lastColumnOrderRef.current) { + console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵"); return; } lastColumnOrderRef.current = columnOrderString; + console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder); // 선택된 행 데이터 가져오기 const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); @@ -919,17 +928,20 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - - // 카테고리 타입: 코드값 → 라벨로 변환 - if (inputType === "category") { - const mapping = categoryMappings[column.columnName]; - if (mapping && value) { - const label = mapping[String(value)]; - if (label) { - return label; - } - } - return String(value); + + // 🖼️ 이미지 타입: 작은 썸네일 표시 + if (inputType === "image" && value && typeof value === "string") { + const imageUrl = getFullImageUrl(value); + return ( + 이미지 { + e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E"; + }} + /> + ); } // 코드 타입: 코드 값 → 코드명 변환 @@ -988,7 +1000,7 @@ export const TableListComponent: React.FC = ({ return String(value); } }, - [columnMeta, categoryMappings, optimizedConvertCode], + [columnMeta, optimizedConvertCode], ); // ======================================== @@ -1238,7 +1250,9 @@ export const TableListComponent: React.FC = ({ sortColumn, sortDirection, searchTerm, + searchValues, // 필터 값 변경 시에도 데이터 새로고침 refreshKey, + refreshTrigger, // 강제 새로고침 트리거 isDesignMode, fetchTableDataDebounced, ]); @@ -1395,15 +1409,15 @@ export const TableListComponent: React.FC = ({ return (
{tableConfig.filter?.enabled && ( -
-
+
+
@@ -1441,7 +1455,7 @@ export const TableListComponent: React.FC = ({ {/* 그룹 표시 배지 */} {groupByColumns.length > 0 && ( -
+
그룹:
@@ -1465,7 +1479,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({ searchValues={searchValues} onSearchValueChange={handleSearchValueChange} onSearch={handleAdvancedSearch} - onClear={handleClearAdvancedFilters} + onClearFilters={handleClearAdvancedFilters} />
@@ -1575,11 +1589,12 @@ export const TableListComponent: React.FC = ({ {/* 테이블 컨테이너 */}
{/* 스크롤 영역 */}
{/* 테이블 */} = ({ > {/* 헤더 (sticky) */} - + {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); @@ -1621,7 +1647,7 @@ export const TableListComponent: React.FC = ({ "relative h-8 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", (column.sortable !== false && column.columnName !== "__checkbox__") && "cursor-pointer hover:bg-muted/70 transition-colors", - isFrozen && "sticky z-20 bg-muted/80 backdrop-blur-sm shadow-[2px_0_4px_rgba(0,0,0,0.1)]" + isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]" )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -1629,6 +1655,7 @@ export const TableListComponent: React.FC = ({ minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, userSelect: 'none', + backgroundColor: "hsl(var(--muted))", ...(isFrozen && { left: `${leftPosition}px` }) }} onClick={() => { @@ -1714,7 +1741,7 @@ export const TableListComponent: React.FC = ({ {/* 바디 (스크롤) */} - + {loading ? (
@@ -1755,11 +1782,11 @@ export const TableListComponent: React.FC = ({
toggleGroupCollapse(group.groupKey)} > {isCollapsed ? ( diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index dd516c7e..9ce5fa21 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -718,7 +718,13 @@ export const TextInputComponent: React.FC = ({ : componentConfig.placeholder || defaultPlaceholder } pattern={validationPattern} - title={webType === "tel" ? "전화번호 형식: 010-1234-5678" : undefined} + title={ + webType === "tel" + ? "전화번호 형식: 010-1234-5678" + : component.label + ? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}` + : component.columnName || undefined + } disabled={componentConfig.disabled || false} required={componentConfig.required || false} readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4781c6c4..c4dc8b86 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1987,7 +1987,12 @@ export class ButtonActionExecutor { */ private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("📤 엑셀 업로드 모달 열기:", { config, context }); + console.log("📤 엑셀 업로드 모달 열기:", { + config, + context, + userId: context.userId, + tableName: context.tableName, + }); // 동적 import로 모달 컴포넌트 로드 const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); @@ -2004,11 +2009,28 @@ export class ButtonActionExecutor { document.body.removeChild(modalContainer); }; + // localStorage 디버깅 + const modalId = `excel-upload-${context.tableName || ""}`; + const storageKey = `modal_size_${modalId}_${context.userId || "guest"}`; + console.log("🔍 엑셀 업로드 모달 localStorage 확인:", { + modalId, + userId: context.userId, + storageKey, + savedSize: localStorage.getItem(storageKey), + }); + root.render( React.createElement(ExcelUploadModal, { open: true, onOpenChange: (open: boolean) => { - if (!open) closeModal(); + if (!open) { + // 모달 닫을 때 localStorage 확인 + console.log("🔍 모달 닫을 때 localStorage:", { + storageKey, + savedSize: localStorage.getItem(storageKey), + }); + closeModal(); + } }, tableName: context.tableName || "", uploadMode: config.excelUploadMode || "insert", diff --git a/frontend/lib/utils/dbTypeMapping.ts b/frontend/lib/utils/dbTypeMapping.ts index 77778d37..a34cb847 100644 --- a/frontend/lib/utils/dbTypeMapping.ts +++ b/frontend/lib/utils/dbTypeMapping.ts @@ -60,6 +60,16 @@ export const DB_TYPE_TO_WEB_TYPE: Record = { * 컬럼명 기반 스마트 웹 타입 추론 규칙 */ export const COLUMN_NAME_TO_WEB_TYPE: Record = { + // 이미지 관련 + image: "image", + img: "image", + picture: "image", + photo: "image", + thumbnail: "image", + avatar: "image", + icon: "image", + logo: "image", + // 이메일 관련 email: "email", mail: "email", diff --git a/frontend/lib/utils/webTypeMapping.ts b/frontend/lib/utils/webTypeMapping.ts index 07aa0e90..89c3fb2a 100644 --- a/frontend/lib/utils/webTypeMapping.ts +++ b/frontend/lib/utils/webTypeMapping.ts @@ -42,6 +42,12 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record = { // 파일 file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 button: "button-primary", diff --git a/frontend/types/input-type-mapping.ts b/frontend/types/input-type-mapping.ts index 6943761c..30d87244 100644 --- a/frontend/types/input-type-mapping.ts +++ b/frontend/types/input-type-mapping.ts @@ -8,7 +8,7 @@ import { WebType } from "./unified-core"; /** - * 8개 핵심 입력 타입 + * 9개 핵심 입력 타입 */ export type BaseInputType = | "text" // 텍스트 @@ -18,7 +18,8 @@ export type BaseInputType = | "entity" // 엔티티 | "select" // 선택박스 | "checkbox" // 체크박스 - | "radio"; // 라디오버튼 + | "radio" // 라디오버튼 + | "image"; // 이미지 /** * 세부 타입 옵션 인터페이스 @@ -92,6 +93,9 @@ export const INPUT_TYPE_DETAIL_TYPES: Record { value: "radio-horizontal", label: "가로 라디오", description: "가로 배치" }, { value: "radio-vertical", label: "세로 라디오", description: "세로 배치" }, ], + + // 이미지 → image + image: [{ value: "image", label: "이미지", description: "이미지 URL 표시" }], }; /** @@ -136,6 +140,9 @@ export function getBaseInputType(webType: WebType): BaseInputType { // entity if (webType === "entity") return "entity"; + // image + if (webType === "image") return "image"; + // 기본값: text return "text"; } @@ -167,6 +174,7 @@ export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: strin { value: "select", label: "선택박스", description: "드롭다운 선택" }, { value: "checkbox", label: "체크박스", description: "체크박스/스위치" }, { value: "radio", label: "라디오버튼", description: "라디오 버튼 그룹" }, + { value: "image", label: "이미지", description: "이미지 표시" }, ]; /** diff --git a/frontend/types/input-types.ts b/frontend/types/input-types.ts index 40753195..e172e620 100644 --- a/frontend/types/input-types.ts +++ b/frontend/types/input-types.ts @@ -15,7 +15,8 @@ export type InputType = | "category" // 카테고리 | "select" // 선택박스 | "checkbox" // 체크박스 - | "radio"; // 라디오버튼 + | "radio" // 라디오버튼 + | "image"; // 이미지 // 입력 타입 옵션 정의 export interface InputTypeOption { @@ -97,6 +98,13 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [ category: "selection", icon: "Circle", }, + { + value: "image", + label: "이미지", + description: "이미지 표시", + category: "basic", + icon: "Image", + }, ]; // 카테고리별 입력 타입 그룹화 diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index 0133f2a6..6f7c2b40 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -36,6 +36,7 @@ export type WebType = | "code" // 공통코드 참조 | "entity" // 엔티티 참조 | "file" // 파일 업로드 + | "image" // 이미지 표시 | "button"; // 버튼 컴포넌트 /**