ui, 파일업로드 관련 손보기
This commit is contained in:
parent
bff7416cd1
commit
a5bf6601a0
|
|
@ -117,10 +117,10 @@ export default function ScreenViewPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
||||
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-600" />
|
||||
<p className="mt-4 text-gray-700 font-medium">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -128,14 +128,14 @@ export default function ScreenViewPage() {
|
|||
|
||||
if (error || !screen) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<span className="text-2xl">⚠️</span>
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8 max-w-md">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-red-100 to-orange-100 shadow-sm">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline">
|
||||
<h2 className="mb-3 text-xl font-bold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-6 text-gray-600 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||||
이전으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -148,17 +148,17 @@ export default function ScreenViewPage() {
|
|||
const screenHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-white pt-10">
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 pt-10">
|
||||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
className="relative bg-white"
|
||||
className="relative bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5 mx-auto"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
|
||||
margin: "0 auto 40px auto", // 하단 여백 추가
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
|
|
@ -178,15 +178,16 @@ export default function ScreenViewPage() {
|
|||
width: `${component.size.width}px`,
|
||||
height: `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
|
||||
border: (component as any).border || "2px dashed #3b82f6",
|
||||
borderRadius: (component as any).borderRadius || "8px",
|
||||
padding: "16px",
|
||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)",
|
||||
border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)",
|
||||
borderRadius: (component as any).borderRadius || "12px",
|
||||
padding: "20px",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
}}
|
||||
>
|
||||
{/* 그룹 제목 */}
|
||||
{(component as any).title && (
|
||||
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
|
||||
<div className="mb-3 text-sm font-semibold text-blue-700 bg-blue-50 px-3 py-1 rounded-lg inline-block">{(component as any).title}</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[9998] rounded-lg border border-gray-200 bg-white shadow-lg",
|
||||
"fixed z-[9998] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||
isResizing && "cursor-se-resize",
|
||||
className,
|
||||
|
|
@ -246,7 +246,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<div
|
||||
ref={dragHandleRef}
|
||||
data-header="true"
|
||||
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
|
||||
className="flex cursor-move items-center justify-between rounded-t-xl border-b border-gray-200/60 bg-gradient-to-r from-gray-50 to-slate-50 p-4"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
userSelect: "none", // 텍스트 선택 방지
|
||||
|
|
@ -259,8 +259,8 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-all duration-200 hover:bg-white/80 hover:shadow-sm">
|
||||
<X className="h-4 w-4 text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -282,7 +282,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
{/* 리사이즈 핸들 */}
|
||||
{resizable && !autoHeight && (
|
||||
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
|
||||
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
|
||||
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gradient-to-br from-gray-400 to-gray-500 shadow-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
RotateCw,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
|
|
@ -1721,7 +1722,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
|
||||
<div className={cn("flex h-full flex-col rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm", className)} style={{ ...style, minHeight: "680px" }}>
|
||||
{/* 헤더 */}
|
||||
<div className="p-6 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -1811,7 +1812,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<div className="flex h-full flex-col">
|
||||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<Table>
|
||||
<div className="rounded-lg border border-gray-200/60 bg-white shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
|
|
@ -1826,7 +1828,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="px-4 font-semibold"
|
||||
className="px-4 font-semibold text-gray-700 bg-gradient-to-r from-gray-50 to-slate-50"
|
||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||
>
|
||||
{column.label}
|
||||
|
|
@ -1850,7 +1852,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="hover:bg-muted/50">
|
||||
<TableRow key={rowIndex} className="hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 transition-all duration-200">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell className="w-12 px-4">
|
||||
|
|
@ -1861,7 +1863,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
||||
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
@ -1884,10 +1886,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{component.pagination?.enabled && totalPages > 1 && (
|
||||
<div className="bg-muted/20 mt-auto border-t">
|
||||
<div className="bg-gradient-to-r from-gray-50 to-slate-50 mt-auto border-t border-gray-200/60">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
{component.pagination.showPageInfo && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
|
|
|||
|
|
@ -1726,17 +1726,19 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
<div className="h-full w-full rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 p-4 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<div className="block" style={labelStyle}>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
<div className="block mb-3" style={labelStyle}>
|
||||
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold">
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 */}
|
||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||
<div className="flex-1 rounded-lg overflow-hidden">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
|
|
|
|||
|
|
@ -440,13 +440,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
isInteractive={true}
|
||||
isDesignMode={false}
|
||||
formData={{
|
||||
screenId, // 화면 ID 전달
|
||||
tableName: screenInfo?.tableName,
|
||||
screenId, // 🎯 화면 ID 전달
|
||||
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||
autoLink: true, // 자동 연결 활성화
|
||||
linkedTable: 'screen_files', // 연결 테이블
|
||||
recordId: screenId, // 레코드 ID
|
||||
columnName: fieldName, // 컬럼명 (중요!)
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||
id: formData.id,
|
||||
...formData
|
||||
}}
|
||||
onFormDataChange={(data) => {
|
||||
console.log("📝 파일 업로드 완료:", data);
|
||||
console.log("📝 실제 화면 파일 업로드 완료:", data);
|
||||
if (onFormDataChange) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
onFormDataChange(key, value);
|
||||
|
|
@ -454,11 +459,57 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
}}
|
||||
onUpdate={(updates) => {
|
||||
console.log("🔄 파일 컴포넌트 업데이트:", updates);
|
||||
// 파일 업로드 완료 시 formData 업데이터
|
||||
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
|
||||
componentId: comp.id,
|
||||
hasUploadedFiles: !!updates.uploadedFiles,
|
||||
filesCount: updates.uploadedFiles?.length || 0,
|
||||
hasLastFileUpdate: !!updates.lastFileUpdate,
|
||||
updates
|
||||
});
|
||||
|
||||
// 파일 업로드/삭제 완료 시 formData 업데이터
|
||||
if (updates.uploadedFiles && onFormDataChange) {
|
||||
onFormDataChange(fieldName, updates.uploadedFiles);
|
||||
}
|
||||
|
||||
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
|
||||
if (updates.uploadedFiles !== undefined && typeof window !== 'undefined') {
|
||||
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
|
||||
const action = updates.lastFileUpdate ? 'update' : 'sync';
|
||||
|
||||
const eventDetail = {
|
||||
componentId: comp.id,
|
||||
files: updates.uploadedFiles,
|
||||
fileCount: updates.uploadedFiles.length,
|
||||
action: action,
|
||||
timestamp: updates.lastFileUpdate || Date.now(),
|
||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||
|
||||
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
||||
setTimeout(() => {
|
||||
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -481,19 +532,19 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
<div className="h-full w-full">
|
||||
<div className="h-full w-full rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 p-4 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
{/* 라벨 표시 - 컴포넌트 내부에서 라벨을 처리하므로 외부에서는 표시하지 않음 */}
|
||||
{!hideLabel && component.label && component.style?.labelDisplay === false && (
|
||||
<div className="mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
<div className="mb-3">
|
||||
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold text-gray-700">
|
||||
{component.label}
|
||||
{(component as WidgetComponent).required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
{(component as WidgetComponent).required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 렌더링 */}
|
||||
<div className="flex-1">{renderInteractiveWidget(component)}</div>
|
||||
<div className="flex-1 rounded-lg overflow-hidden">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -127,31 +127,15 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
|||
className: `w-full h-full ${borderClass}`,
|
||||
};
|
||||
|
||||
// 파일 컴포넌트 강제 체크
|
||||
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
||||
if (isFileComponent(widget)) {
|
||||
console.log("🎯 RealtimePreview - 파일 컴포넌트 강제 감지:", {
|
||||
console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
||||
componentId: widget.id,
|
||||
widgetType: widgetType,
|
||||
isFileComponent: true
|
||||
});
|
||||
|
||||
try {
|
||||
return (
|
||||
<DynamicWebTypeRenderer
|
||||
webType="file"
|
||||
props={{
|
||||
...commonProps,
|
||||
component: widget,
|
||||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`파일 웹타입 렌더링 실패:`, error);
|
||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (렌더링 오류)</div>;
|
||||
}
|
||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (별도 렌더링)</div>;
|
||||
}
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
|
|
@ -242,24 +226,84 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
||||
eventComponentId: event.detail.componentId,
|
||||
currentComponentId: component.id,
|
||||
isMatch: event.detail.componentId === component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action,
|
||||
delayed: event.detail.delayed || false,
|
||||
attempt: event.detail.attempt || 1,
|
||||
eventDetail: event.detail
|
||||
});
|
||||
|
||||
if (event.detail.componentId === component.id) {
|
||||
console.log("🔄 RealtimePreview 파일 상태 변경 감지:", {
|
||||
console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
||||
componentId: component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action
|
||||
action: event.detail.action,
|
||||
oldTrigger: fileUpdateTrigger,
|
||||
delayed: event.detail.delayed || false,
|
||||
attempt: event.detail.attempt || 1
|
||||
});
|
||||
setFileUpdateTrigger(prev => {
|
||||
const newTrigger = prev + 1;
|
||||
console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||
old: prev,
|
||||
new: newTrigger,
|
||||
componentId: component.id,
|
||||
attempt: event.detail.attempt || 1
|
||||
});
|
||||
return newTrigger;
|
||||
});
|
||||
} else {
|
||||
console.log("❌ 컴포넌트 ID 불일치:", {
|
||||
eventComponentId: event.detail.componentId,
|
||||
currentComponentId: component.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 강제 업데이트 함수 등록
|
||||
const forceUpdate = (componentId: string, files: any[]) => {
|
||||
console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
||||
targetComponentId: componentId,
|
||||
currentComponentId: component.id,
|
||||
isMatch: componentId === component.id,
|
||||
filesCount: files.length
|
||||
});
|
||||
|
||||
if (componentId === component.id) {
|
||||
console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
||||
componentId: component.id,
|
||||
filesCount: files.length,
|
||||
oldTrigger: fileUpdateTrigger
|
||||
});
|
||||
setFileUpdateTrigger(prev => {
|
||||
const newTrigger = prev + 1;
|
||||
console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||
old: prev,
|
||||
new: newTrigger,
|
||||
componentId: component.id
|
||||
});
|
||||
return newTrigger;
|
||||
});
|
||||
setFileUpdateTrigger(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
|
||||
// 전역 강제 업데이트 함수 등록
|
||||
if (!(window as any).forceRealtimePreviewUpdate) {
|
||||
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id]);
|
||||
}, [component.id, fileUpdateTrigger]);
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const componentStyle = {
|
||||
|
|
@ -350,8 +394,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 */}
|
||||
{type === "widget" && (
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">
|
||||
<WidgetRenderer component={component} />
|
||||
|
|
@ -383,7 +427,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||
{currentFiles.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -754,33 +754,61 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
if (response.success && response.componentFiles) {
|
||||
console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
||||
|
||||
// 각 컴포넌트별로 파일 복원
|
||||
Object.entries(response.componentFiles).forEach(([componentId, files]) => {
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
// 전역 상태에 파일 저장
|
||||
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
|
||||
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
|
||||
if (Array.isArray(serverFiles) && serverFiles.length > 0) {
|
||||
// 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
globalFileState[componentId] = files;
|
||||
const currentGlobalFiles = globalFileState[componentId] || [];
|
||||
|
||||
let currentLocalStorageFiles: any[] = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`);
|
||||
if (storedFiles) {
|
||||
currentLocalStorageFiles = JSON.parse(storedFiles);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("localStorage 파일 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터
|
||||
let finalFiles = serverFiles;
|
||||
if (currentGlobalFiles.length > 0) {
|
||||
finalFiles = currentGlobalFiles;
|
||||
console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
|
||||
} else if (currentLocalStorageFiles.length > 0) {
|
||||
finalFiles = currentLocalStorageFiles;
|
||||
console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
|
||||
} else {
|
||||
console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
|
||||
}
|
||||
|
||||
// 전역 상태에 파일 저장
|
||||
globalFileState[componentId] = finalFiles;
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = globalFileState;
|
||||
}
|
||||
|
||||
// localStorage에도 백업
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(files));
|
||||
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles));
|
||||
}
|
||||
|
||||
console.log(`📂 컴포넌트 ${componentId} 파일 복원:`, files.length, "개");
|
||||
}
|
||||
});
|
||||
|
||||
// 레이아웃의 컴포넌트들에 파일 정보 적용
|
||||
// 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선)
|
||||
setLayout(prevLayout => {
|
||||
const updatedComponents = prevLayout.components.map(comp => {
|
||||
const componentFiles = response.componentFiles[comp.id];
|
||||
if (componentFiles && componentFiles.length > 0) {
|
||||
// 🎯 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const finalFiles = globalFileState[comp.id] || [];
|
||||
|
||||
if (finalFiles.length > 0) {
|
||||
return {
|
||||
...comp,
|
||||
uploadedFiles: componentFiles,
|
||||
uploadedFiles: finalFiles,
|
||||
lastFileUpdate: Date.now()
|
||||
};
|
||||
}
|
||||
|
|
@ -3368,7 +3396,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-gray-100">
|
||||
<div className="flex h-screen w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
{/* 상단 툴바 */}
|
||||
<DesignerToolbar
|
||||
screenName={selectedScreen?.screenName}
|
||||
|
|
@ -3388,7 +3416,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
/>
|
||||
|
||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
|
||||
<div className="relative flex-1 overflow-auto bg-gray-100 px-2 py-6">
|
||||
<div className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6">
|
||||
{/* 해상도 정보 표시 - 적당한 여백 */}
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -125,126 +125,145 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Package className="mr-2 h-5 w-5" />
|
||||
컴포넌트 ({componentsByCategory.all.length})
|
||||
<div className={`h-full bg-gradient-to-br from-slate-50 to-purple-50/30 border-r border-gray-200/60 shadow-sm ${className}`}>
|
||||
<div className="p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">컴포넌트</h2>
|
||||
<p className="text-sm text-gray-500">{componentsByCategory.all.length}개의 사용 가능한 컴포넌트</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
title="컴포넌트 새로고침"
|
||||
className="bg-white/60 border-gray-200/60 hover:bg-white hover:border-gray-300"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="컴포넌트 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<div className="px-6">
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
||||
>
|
||||
{/* 카테고리 탭 (input 카테고리 제외) */}
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
|
||||
<TabsTrigger value="all" className="flex items-center">
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5 bg-white/60 backdrop-blur-sm border-0 p-1">
|
||||
<TabsTrigger value="all" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
전체
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center">
|
||||
<TabsTrigger value="display" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Palette className="mr-1 h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center">
|
||||
<TabsTrigger value="action" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
액션
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="layout" className="flex items-center">
|
||||
<TabsTrigger value="layout" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Layers className="mr-1 h-3 w-3" />
|
||||
레이아웃
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utility" className="flex items-center">
|
||||
<TabsTrigger value="utility" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
유틸
|
||||
유틸리티
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="mt-4">
|
||||
<TabsContent value={selectedCategory} className="space-y-2">
|
||||
<div className="mt-6">
|
||||
<TabsContent value={selectedCategory} className="space-y-3">
|
||||
{filteredComponents.length > 0 ? (
|
||||
<div className="grid max-h-96 grid-cols-1 gap-2 overflow-y-auto">
|
||||
<div className="grid max-h-96 grid-cols-1 gap-3 overflow-y-auto pr-2">
|
||||
{filteredComponents.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, component)}
|
||||
className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing"
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(e, component);
|
||||
// 드래그 시작 시 시각적 피드백
|
||||
e.currentTarget.style.opacity = '0.5';
|
||||
e.currentTarget.style.transform = 'rotate(-3deg) scale(0.95)';
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
// 드래그 종료 시 원래 상태로 복원
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||
title={component.description}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h4 className="truncate text-sm font-medium">{component.name}</h4>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 카테고리 뱃지 */}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getCategoryIcon(component.category)}
|
||||
<span className="ml-1">{component.category}</span>
|
||||
</Badge>
|
||||
|
||||
{/* 새 컴포넌트 뱃지 */}
|
||||
<Badge variant="default" className="bg-green-500 text-xs">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
||||
{getCategoryIcon(component.category)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{component.name}</h4>
|
||||
<Badge variant="default" className="bg-gradient-to-r from-emerald-500 to-emerald-600 text-white text-xs border-0 ml-2 px-2 py-1 rounded-full font-medium shadow-sm">
|
||||
신규
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground truncate text-xs">{component.description}</p>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{component.description}</p>
|
||||
|
||||
{/* 웹타입 및 크기 정보 */}
|
||||
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
||||
<span>웹타입: {component.webType}</span>
|
||||
<span>
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
{component.tags && component.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{component.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{component.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{component.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-purple-600 capitalize bg-gradient-to-r from-purple-50 to-indigo-50 px-3 py-1 rounded-full border border-purple-200/50">
|
||||
{component.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 태그 */}
|
||||
{component.tags && component.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{component.tags.slice(0, 2).map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border-gray-200/50 rounded-full px-2 py-1">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{component.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border-gray-200/50 rounded-full px-2 py-1">
|
||||
+{component.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? `"${searchQuery}"에 대한 검색 결과가 없습니다.`
|
||||
: "이 카테고리에 컴포넌트가 없습니다."}
|
||||
</p>
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<div className="p-8">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{searchQuery
|
||||
? `"${searchQuery}"에 대한 컴포넌트를 찾을 수 없습니다`
|
||||
: "이 카테고리에 컴포넌트가 없습니다"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
@ -252,31 +271,40 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
</Tabs>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mt-6 rounded-xl bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-100/60 p-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-green-600">{filteredComponents.length}</div>
|
||||
<div className="text-muted-foreground text-xs">표시된 컴포넌트</div>
|
||||
<div className="text-lg font-bold text-emerald-600">{filteredComponents.length}</div>
|
||||
<div className="text-xs text-gray-500">필터됨</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600">{allComponents.length}</div>
|
||||
<div className="text-muted-foreground text-xs">전체 컴포넌트</div>
|
||||
<div className="text-lg font-bold text-purple-600">{allComponents.length}</div>
|
||||
<div className="text-xs text-gray-500">전체</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개발 정보 (개발 모드에서만) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>🔧 레지스트리 기반 시스템</div>
|
||||
<div>⚡ Hot Reload 지원</div>
|
||||
<div>🛡️ 완전한 타입 안전성</div>
|
||||
<div className="mt-4 rounded-xl bg-gradient-to-r from-gray-50 to-slate-50 border border-gray-100/60 p-4">
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
<span>레지스트리 기반 시스템</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
<span>Hot Reload 지원</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-purple-500 rounded-full"></span>
|
||||
<span>완전한 타입 안전성</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
currentTable,
|
||||
currentTableName,
|
||||
}) => {
|
||||
console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
|
||||
componentId: component?.id,
|
||||
componentType: component?.type,
|
||||
hasOnUpdateProperty: !!onUpdateProperty,
|
||||
currentTable,
|
||||
currentTableName
|
||||
});
|
||||
// fileConfig가 없는 경우 초기화
|
||||
React.useEffect(() => {
|
||||
if (!component.fileConfig) {
|
||||
|
|
@ -112,13 +119,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
const componentFiles = component.uploadedFiles || [];
|
||||
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||
|
||||
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일)
|
||||
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일 + FileUploadComponent 백업)
|
||||
const backupKey = `fileComponent_${component.id}_files`;
|
||||
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
|
||||
const fileUploadBackupKey = `fileUpload_${component.id}`; // 🎯 실제 화면과 동기화
|
||||
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
const tempBackupFiles = localStorage.getItem(tempBackupKey);
|
||||
const fileUploadBackupFiles = localStorage.getItem(fileUploadBackupKey); // 🎯 실제 화면 백업
|
||||
|
||||
let parsedBackupFiles: FileInfo[] = [];
|
||||
let parsedTempFiles: FileInfo[] = [];
|
||||
let parsedFileUploadFiles: FileInfo[] = []; // 🎯 실제 화면 파일
|
||||
|
||||
if (backupFiles) {
|
||||
try {
|
||||
|
|
@ -136,8 +148,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
}
|
||||
}
|
||||
|
||||
// 우선순위: 전역 상태 > localStorage > 임시 파일 > 컴포넌트 속성
|
||||
// 🎯 실제 화면 FileUploadComponent 백업 파싱
|
||||
if (fileUploadBackupFiles) {
|
||||
try {
|
||||
parsedFileUploadFiles = JSON.parse(fileUploadBackupFiles);
|
||||
} catch (error) {
|
||||
console.error("FileUploadComponent 백업 파일 파싱 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 우선순위: 전역 상태 > FileUploadComponent 백업 > localStorage > 임시 파일 > 컴포넌트 속성
|
||||
const finalFiles = globalFiles.length > 0 ? globalFiles :
|
||||
parsedFileUploadFiles.length > 0 ? parsedFileUploadFiles : // 🎯 실제 화면 우선
|
||||
parsedBackupFiles.length > 0 ? parsedBackupFiles :
|
||||
parsedTempFiles.length > 0 ? parsedTempFiles :
|
||||
componentFiles;
|
||||
|
|
@ -148,8 +170,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
globalFiles: globalFiles.length,
|
||||
backupFiles: parsedBackupFiles.length,
|
||||
tempFiles: parsedTempFiles.length,
|
||||
fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
|
||||
finalFiles: finalFiles.length,
|
||||
source: globalFiles.length > 0 ? 'global' : parsedBackupFiles.length > 0 ? 'localStorage' : parsedTempFiles.length > 0 ? 'temp' : 'component'
|
||||
source: globalFiles.length > 0 ? 'global' :
|
||||
parsedFileUploadFiles.length > 0 ? 'fileUploadComponent' : // 🎯 실제 화면 소스
|
||||
parsedBackupFiles.length > 0 ? 'localStorage' :
|
||||
parsedTempFiles.length > 0 ? 'temp' : 'component'
|
||||
});
|
||||
|
||||
return finalFiles;
|
||||
|
|
@ -190,7 +216,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 파일 업로드 처리
|
||||
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
console.log("🚀🚀🚀 FileComponentConfigPanel 파일 업로드 시작:", {
|
||||
filesCount: files?.length || 0,
|
||||
componentId: component?.id,
|
||||
componentType: component?.type,
|
||||
hasOnUpdateProperty: !!onUpdateProperty
|
||||
});
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.log("❌ 파일이 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
const validFiles: File[] = [];
|
||||
|
|
@ -291,23 +327,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
setUploading(true);
|
||||
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
|
||||
|
||||
// 그리드와 연동되는 targetObjid 생성 (화면 복원 시스템과 통일)
|
||||
const tableName = 'screen_files';
|
||||
const screenId = (window as any).__CURRENT_SCREEN_ID__ || 'unknown'; // 현재 화면 ID
|
||||
// 🎯 여러 방법으로 screenId 확인
|
||||
let screenId = (window as any).__CURRENT_SCREEN_ID__;
|
||||
|
||||
// 1차: 전역 변수에서 가져오기
|
||||
if (!screenId) {
|
||||
// 2차: URL에서 추출 시도
|
||||
if (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')) {
|
||||
const pathScreenId = window.location.pathname.split('/screens/')[1];
|
||||
if (pathScreenId && !isNaN(parseInt(pathScreenId))) {
|
||||
screenId = parseInt(pathScreenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3차: 기본값 설정
|
||||
if (!screenId) {
|
||||
screenId = 40; // 기본 화면 ID (디자인 모드용)
|
||||
console.warn("⚠️ screenId를 찾을 수 없어 기본값(40) 사용");
|
||||
}
|
||||
|
||||
const componentId = component.id;
|
||||
const fieldName = component.columnName || component.id || 'file_attachment';
|
||||
const targetObjid = `${tableName}:${screenId}:${componentId}:${fieldName}`;
|
||||
|
||||
console.log("📋 파일 업로드 기본 정보:", {
|
||||
screenId,
|
||||
screenIdSource: (window as any).__CURRENT_SCREEN_ID__ ? 'global' : 'url_or_default',
|
||||
componentId,
|
||||
fieldName,
|
||||
docType: localInputs.docType,
|
||||
docTypeName: localInputs.docTypeName,
|
||||
currentPath: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
|
||||
});
|
||||
|
||||
const response = await uploadFiles({
|
||||
files: filesToUpload,
|
||||
tableName: tableName,
|
||||
fieldName: fieldName,
|
||||
recordId: `screen_${screenId}:${componentId}`, // 템플릿 파일 형태
|
||||
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||
autoLink: true, // 자동 연결 활성화
|
||||
linkedTable: 'screen_files', // 연결 테이블
|
||||
recordId: screenId, // 레코드 ID
|
||||
columnName: fieldName, // 컬럼명
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||
docType: localInputs.docType,
|
||||
docTypeName: localInputs.docTypeName,
|
||||
targetObjid: targetObjid, // 그리드 연동을 위한 targetObjid
|
||||
columnName: fieldName,
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
|
||||
});
|
||||
|
||||
console.log("📤 파일 업로드 응답:", response);
|
||||
|
|
@ -360,15 +422,61 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 전역 파일 상태 변경 이벤트 발생 (RealtimePreview 업데이트용)
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'upload',
|
||||
timestamp: Date.now(),
|
||||
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 FileComponentConfigPanel 이벤트 발생:", eventDetail);
|
||||
console.log("🔍 현재 컴포넌트 ID:", component.id);
|
||||
console.log("🔍 업로드된 파일 수:", updatedFiles.length);
|
||||
console.log("🔍 파일 목록:", updatedFiles.map(f => f.name));
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
action: 'upload',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
detail: eventDetail
|
||||
});
|
||||
|
||||
// 이벤트 리스너가 있는지 확인
|
||||
const listenerCount = window.getEventListeners ?
|
||||
window.getEventListeners(window)?.globalFileStateChanged?.length || 0 :
|
||||
'unknown';
|
||||
console.log("🔍 globalFileStateChanged 리스너 수:", listenerCount);
|
||||
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ globalFileStateChanged 이벤트 발생 완료");
|
||||
|
||||
// 강제로 모든 RealtimePreview 컴포넌트에게 알림 (여러 번)
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 이벤트 발생 (지연 500ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 3 }
|
||||
}));
|
||||
}, 500);
|
||||
|
||||
// 직접 전역 상태 강제 업데이트
|
||||
console.log("🔄 전역 상태 강제 업데이트 시도");
|
||||
if ((window as any).forceRealtimePreviewUpdate) {
|
||||
(window as any).forceRealtimePreviewUpdate(component.id, updatedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
|
||||
|
|
@ -417,10 +525,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
} else {
|
||||
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 파일 업로드 오류:', error);
|
||||
} catch (error: any) {
|
||||
console.error('❌ 파일 업로드 오류:', {
|
||||
error,
|
||||
errorMessage: error?.message,
|
||||
errorResponse: error?.response?.data,
|
||||
errorStatus: error?.response?.status,
|
||||
componentId: component?.id,
|
||||
screenId,
|
||||
fieldName
|
||||
});
|
||||
toast.dismiss();
|
||||
toast.error('파일 업로드에 실패했습니다.');
|
||||
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
|
||||
} finally {
|
||||
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
|
||||
setUploading(false);
|
||||
|
|
@ -444,8 +560,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 파일 삭제 처리
|
||||
const handleFileDelete = useCallback(async (fileId: string) => {
|
||||
console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
|
||||
fileId,
|
||||
componentId: component?.id,
|
||||
currentFilesCount: uploadedFiles.length,
|
||||
hasOnUpdateProperty: !!onUpdateProperty
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("📡 deleteFile API 호출 시작...");
|
||||
await deleteFile(fileId, 'temp_record');
|
||||
console.log("✅ deleteFile API 호출 성공");
|
||||
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
|
||||
setUploadedFiles(updatedFiles);
|
||||
|
||||
|
|
@ -473,8 +598,42 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
timestamp: timestamp
|
||||
});
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'delete',
|
||||
timestamp: timestamp,
|
||||
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
|
||||
|
||||
// 추가 지연 이벤트들
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트도 유지
|
||||
const tableName = currentTableName || 'screen_files';
|
||||
const recordId = component.id;
|
||||
const columnName = component.columnName || component.id || 'file_attachment';
|
||||
|
|
@ -557,12 +716,22 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = e.dataTransfer.files;
|
||||
console.log("📂 드래그앤드롭 이벤트:", {
|
||||
filesCount: files.length,
|
||||
files: files.length > 0 ? Array.from(files).map(f => f.name) : [],
|
||||
componentId: component?.id
|
||||
});
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
}, [handleFileUpload]);
|
||||
}, [handleFileUpload, component?.id]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log("📁 파일 선택 이벤트:", {
|
||||
filesCount: e.target.files?.length || 0,
|
||||
files: e.target.files ? Array.from(e.target.files).map(f => f.name) : []
|
||||
});
|
||||
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
|
|
@ -667,20 +836,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
// 전역 파일 상태 변경 감지 (화면 복원 포함)
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
const { componentId, files, fileCount, isRestore } = event.detail;
|
||||
const { componentId, files, fileCount, isRestore, source } = event.detail;
|
||||
|
||||
if (componentId === component.id) {
|
||||
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
|
||||
componentId,
|
||||
fileCount,
|
||||
isRestore: !!isRestore,
|
||||
source: source || 'unknown',
|
||||
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||
});
|
||||
|
||||
if (files && Array.isArray(files)) {
|
||||
setUploadedFiles(files);
|
||||
|
||||
if (isRestore) {
|
||||
// 🎯 실제 화면에서 온 이벤트이거나 화면 복원인 경우 컴포넌트 속성도 업데이트
|
||||
if (isRestore || source === 'realScreen') {
|
||||
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 적용:", {
|
||||
componentId,
|
||||
fileCount: files.length,
|
||||
source: source || 'restore'
|
||||
});
|
||||
|
||||
onUpdateProperty(component.id, "uploadedFiles", files);
|
||||
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
|
||||
|
||||
// localStorage 백업도 업데이트
|
||||
try {
|
||||
const backupKey = `fileComponent_${component.id}_files`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||
console.log("💾 실제 화면 동기화 후 localStorage 백업 업데이트:", {
|
||||
componentId: component.id,
|
||||
fileCount: files.length
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트
|
||||
setGlobalFileState(prev => ({
|
||||
...prev,
|
||||
[component.id]: files
|
||||
}));
|
||||
} else if (isRestore) {
|
||||
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
|
||||
componentId,
|
||||
restoredFileCount: files.length
|
||||
|
|
@ -697,7 +895,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id]);
|
||||
}, [component.id, onUpdateProperty]);
|
||||
|
||||
// 미리 정의된 문서 타입들
|
||||
const docTypeOptions = [
|
||||
|
|
@ -893,18 +1091,33 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
{/* 파일 업로드 영역 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">파일 업로드</h4>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Card className="border-gray-200/60 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300
|
||||
${dragOver ? 'border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm' : 'border-gray-300/60'}
|
||||
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm'}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && document.getElementById('file-input-config')?.click()}
|
||||
onClick={() => {
|
||||
console.log("🖱️ 파일 업로드 영역 클릭:", {
|
||||
uploading,
|
||||
inputElement: document.getElementById('file-input-config'),
|
||||
componentId: component?.id
|
||||
});
|
||||
if (!uploading) {
|
||||
const input = document.getElementById('file-input-config');
|
||||
if (input) {
|
||||
console.log("✅ 파일 input 클릭 실행");
|
||||
input.click();
|
||||
} else {
|
||||
console.log("❌ 파일 input 요소를 찾을 수 없음");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="file-input-config"
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export default function LayoutsPanel({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`layouts-panel h-full ${className || ""}`}>
|
||||
<div className={`layouts-panel h-full bg-gradient-to-br from-slate-50 to-indigo-50/30 border-r border-gray-200/60 shadow-sm ${className || ""}`}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
|
|
|
|||
|
|
@ -487,16 +487,22 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50/30 p-6 border-r border-gray-200/60 shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">템플릿</h2>
|
||||
<p className="text-sm text-gray-500">캔버스로 드래그하여 화면을 구성하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="템플릿 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -508,7 +514,13 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className="flex items-center space-x-1"
|
||||
className={`
|
||||
flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all
|
||||
${selectedCategory === category.id
|
||||
? 'bg-blue-600 text-white shadow-sm hover:bg-blue-700'
|
||||
: 'bg-white/60 text-gray-600 border-gray-200/60 hover:bg-white hover:text-gray-900 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{category.icon}
|
||||
<span>{category.name}</span>
|
||||
|
|
@ -517,23 +529,21 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 새로고침 버튼 */}
|
||||
{error && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-yellow-50 p-3 text-yellow-800">
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50/80 border border-amber-200/60 p-3 text-amber-800 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="text-sm">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
|
|
@ -541,9 +551,10 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
||||
<div>
|
||||
<FileText className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
<div className="p-8">
|
||||
<FileText className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-600">템플릿을 찾을 수 없습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -551,27 +562,40 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
<div
|
||||
key={template.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, template)}
|
||||
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
|
||||
onDragStart={(e) => {
|
||||
onDragStart(e, template);
|
||||
// 드래그 시작 시 시각적 피드백
|
||||
e.currentTarget.style.opacity = '0.6';
|
||||
e.currentTarget.style.transform = 'rotate(2deg) scale(0.98)';
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
// 드래그 종료 시 원래 상태로 복원
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-blue-500/15 hover:scale-[1.02] hover:border-blue-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
||||
{template.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{template.components.length}개
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{template.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 border-0 ml-2 px-2 py-1 rounded-full font-medium">
|
||||
{template.components.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
|
||||
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span>
|
||||
{template.defaultSize.width}×{template.defaultSize.height}
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{template.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
|
||||
{template.defaultSize.width}×{template.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-blue-600 capitalize bg-gradient-to-r from-blue-50 to-indigo-50 px-3 py-1 rounded-full border border-blue-200/50">
|
||||
{template.category}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{template.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -581,12 +605,14 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
</div>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
|
||||
<div className="text-xs text-blue-700">
|
||||
<p className="mb-1 font-medium">사용 방법</p>
|
||||
<p>템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
||||
<div className="rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100/60 p-4 mt-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<Info className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-semibold mb-1">사용 방법</p>
|
||||
<p className="text-blue-600 leading-relaxed">템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -504,6 +504,26 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
[component.id]: updatedFiles
|
||||
}));
|
||||
|
||||
// RealtimePreview 동기화를 위한 추가 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'upload',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료");
|
||||
}
|
||||
|
||||
// 컴포넌트 업데이트 (옵셔널)
|
||||
if (onUpdateComponent) {
|
||||
onUpdateComponent({
|
||||
|
|
@ -583,6 +603,42 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
[component.id]: filteredFiles
|
||||
}));
|
||||
|
||||
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: filteredFiles,
|
||||
fileCount: filteredFiles.length,
|
||||
action: 'delete',
|
||||
timestamp: Date.now(),
|
||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||
|
||||
// 추가 지연 이벤트들
|
||||
setTimeout(() => {
|
||||
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onUpdateComponent({
|
||||
uploadedFiles: filteredFiles,
|
||||
});
|
||||
|
|
@ -635,8 +691,8 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
<div className="w-full space-y-4">
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
|
||||
isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"
|
||||
className={`rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300 ${
|
||||
isDragOver ? "border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm" : "border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -648,7 +704,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">또는 클릭하여 파일을 선택하세요</p>
|
||||
|
||||
<Button variant="outline" onClick={handleFileInputClick} className="mb-4">
|
||||
<Button variant="outline" onClick={handleFileInputClick} className="mb-4 rounded-lg border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 transition-all duration-200">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{fileConfig.uploadButtonText || "파일 선택"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
<div className="h-full w-full space-y-2">
|
||||
{/* 파일 업로드 영역 */}
|
||||
<div
|
||||
className="border-muted-foreground/25 hover:border-muted-foreground/50 cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors"
|
||||
className="border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 cursor-pointer rounded-xl border-2 border-dashed p-6 text-center transition-all duration-300 hover:shadow-sm"
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
|
|
|
|||
|
|
@ -244,33 +244,39 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 컨테이너 스타일 (원래 카드 레이아웃과 완전히 동일)
|
||||
// 컨테이너 스타일 - 통일된 디자인 시스템 적용
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
||||
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
||||
gap: `${componentConfig.cardSpacing || 16}px`,
|
||||
padding: "16px",
|
||||
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
|
||||
padding: "32px", // 패딩 대폭 증가
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "transparent",
|
||||
background: "linear-gradient(to br, #f8fafc, #f1f5f9)", // 부드러운 그라데이션 배경
|
||||
overflow: "auto",
|
||||
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
||||
};
|
||||
|
||||
// 카드 스타일 (원래 카드 레이아웃과 완전히 동일)
|
||||
// 카드 스타일 - 통일된 디자인 시스템 적용
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
border: "1px solid #e2e8f0", // 더 부드러운 보더 색상
|
||||
borderRadius: "12px", // 통일된 라운드 처리
|
||||
padding: "24px", // 더 여유로운 패딩
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
minHeight: "200px",
|
||||
minHeight: "240px", // 최소 높이 더 증가
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
// 호버 효과를 위한 추가 스타일
|
||||
"&:hover": {
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||
}
|
||||
};
|
||||
|
||||
// 텍스트 자르기 함수
|
||||
|
|
@ -386,53 +392,53 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
<div
|
||||
key={data.id || index}
|
||||
style={cardStyle}
|
||||
className="card-hover"
|
||||
className="group cursor-pointer hover:transform hover:-translate-y-1 hover:shadow-xl transition-all duration-300 ease-out"
|
||||
onClick={() => handleCardClick(data)}
|
||||
>
|
||||
{/* 카드 이미지 */}
|
||||
{/* 카드 이미지 - 통일된 디자인 */}
|
||||
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
||||
<div className="mb-3 flex justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
|
||||
<span className="text-xl text-gray-500">👤</span>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm border-2 border-white">
|
||||
<span className="text-2xl text-blue-600">👤</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 타이틀 */}
|
||||
{/* 카드 타이틀 - 통일된 디자인 */}
|
||||
{componentConfig.cardStyle?.showTitle && (
|
||||
<div className="mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{titleValue}</h3>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xl font-bold text-gray-900 leading-tight">{titleValue}</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 서브타이틀 */}
|
||||
{/* 카드 서브타이틀 - 통일된 디자인 */}
|
||||
{componentConfig.cardStyle?.showSubtitle && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm font-medium text-blue-600">{subtitleValue}</p>
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-semibold text-blue-600 bg-blue-50 px-3 py-1 rounded-full inline-block">{subtitleValue}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 설명 */}
|
||||
{/* 카드 설명 - 통일된 디자인 */}
|
||||
{componentConfig.cardStyle?.showDescription && (
|
||||
<div className="mb-3 flex-1">
|
||||
<p className="text-sm leading-relaxed text-gray-600">
|
||||
<div className="mb-4 flex-1">
|
||||
<p className="text-sm leading-relaxed text-gray-700 bg-gray-50 p-3 rounded-lg">
|
||||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 표시 컬럼들 */}
|
||||
{/* 추가 표시 컬럼들 - 통일된 디자인 */}
|
||||
{componentConfig.columnMapping?.displayColumns &&
|
||||
componentConfig.columnMapping.displayColumns.length > 0 && (
|
||||
<div className="space-y-1 border-t border-gray-100 pt-3">
|
||||
<div className="space-y-2 border-t border-gray-200 pt-4">
|
||||
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
||||
const value = getColumnValue(data, columnName);
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex justify-between text-xs">
|
||||
<span className="text-gray-500 capitalize">{getColumnLabel(columnName)}:</span>
|
||||
<span className="font-medium text-gray-700">{value}</span>
|
||||
<div key={idx} className="flex justify-between items-center text-sm bg-white/50 px-3 py-2 rounded-lg border border-gray-100">
|
||||
<span className="text-gray-600 font-medium capitalize">{getColumnLabel(columnName)}:</span>
|
||||
<span className="font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md text-xs">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -131,15 +131,90 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
}, [component.id]); // component.id가 변경될 때만 실행
|
||||
|
||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleDesignModeFileChange = (event: CustomEvent) => {
|
||||
console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", {
|
||||
eventComponentId: event.detail.componentId,
|
||||
currentComponentId: component.id,
|
||||
isMatch: event.detail.componentId === component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action,
|
||||
source: event.detail.source,
|
||||
eventDetail: event.detail
|
||||
});
|
||||
|
||||
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
|
||||
if (event.detail.componentId === component.id && event.detail.source === 'designMode') {
|
||||
console.log("✅✅✅ 화면설계 모드 → 실제 화면 파일 동기화 시작:", {
|
||||
componentId: component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action
|
||||
});
|
||||
|
||||
// 파일 상태 업데이트
|
||||
const newFiles = event.detail.files || [];
|
||||
setUploadedFiles(newFiles);
|
||||
|
||||
// localStorage 백업 업데이트
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(newFiles));
|
||||
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
|
||||
componentId: component.id,
|
||||
fileCount: newFiles.length
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = {
|
||||
...(window as any).globalFileState,
|
||||
[component.id]: newFiles
|
||||
};
|
||||
}
|
||||
|
||||
// onUpdate 콜백 호출 (부모 컴포넌트에 알림)
|
||||
if (onUpdate) {
|
||||
onUpdate({
|
||||
uploadedFiles: newFiles,
|
||||
lastFileUpdate: event.detail.timestamp
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", {
|
||||
componentId: component.id,
|
||||
finalFileCount: newFiles.length
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('globalFileStateChanged', handleDesignModeFileChange as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalFileStateChanged', handleDesignModeFileChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id, onUpdate]);
|
||||
|
||||
// 템플릿 파일과 데이터 파일을 조회하는 함수
|
||||
const loadComponentFiles = useCallback(async () => {
|
||||
if (!component?.id) return;
|
||||
|
||||
try {
|
||||
const screenId = formData?.screenId || (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
let screenId = formData?.screenId || (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
: null);
|
||||
|
||||
// 디자인 모드인 경우 기본 화면 ID 사용
|
||||
if (!screenId && isDesignMode) {
|
||||
screenId = 40; // 기본 화면 ID
|
||||
console.log("📂 디자인 모드: 기본 화면 ID 사용 (40)");
|
||||
}
|
||||
|
||||
if (!screenId) {
|
||||
console.log("📂 화면 ID 없음, 기존 파일 로직 사용");
|
||||
return false; // 기존 로직 사용
|
||||
|
|
@ -474,14 +549,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
|
||||
const uploadData = {
|
||||
tableName: tableName,
|
||||
fieldName: columnName,
|
||||
recordId: recordId || `temp_${component.id}`,
|
||||
// 🎯 formData에서 백엔드 API 설정 가져오기
|
||||
autoLink: formData?.autoLink || true,
|
||||
linkedTable: formData?.linkedTable || tableName,
|
||||
recordId: formData?.recordId || recordId || `temp_${component.id}`,
|
||||
columnName: formData?.columnName || columnName,
|
||||
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
||||
docType: component.fileConfig?.docType || 'DOCUMENT',
|
||||
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
|
||||
// 호환성을 위한 기존 필드들
|
||||
tableName: tableName,
|
||||
fieldName: columnName,
|
||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||
columnName: columnName, // 가상 파일 컬럼 지원
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
|
||||
};
|
||||
|
||||
console.log("📤 파일 업로드 시작:", {
|
||||
|
|
@ -715,7 +794,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
source: 'realScreen', // 🎯 실제 화면에서 온 이벤트임을 표시
|
||||
action: 'delete'
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full overflow-auto"
|
||||
className="relative h-full overflow-auto rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
|
|
@ -62,8 +62,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
|
||||
<TableRow>
|
||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-gradient-to-r from-slate-50 to-blue-50/30 border-b border-gray-200/60" : "bg-gradient-to-r from-slate-50 to-blue-50/30 border-b border-gray-200/60"}>
|
||||
<TableRow className="border-b border-gray-200/40">
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = visibleColumns
|
||||
|
|
@ -84,13 +84,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
key={column.columnName}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-10 border-b px-4 py-2 text-center align-middle"
|
||||
: "h-10 cursor-pointer border-b px-4 py-2 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||
? "h-12 border-0 px-4 py-3 text-center align-middle"
|
||||
: "h-12 cursor-pointer border-0 px-4 py-3 text-left align-middle font-semibold whitespace-nowrap text-slate-700 select-none transition-all duration-200",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-gray-50",
|
||||
column.sortable && "hover:bg-blue-50/50 hover:text-blue-700",
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "sticky z-10 border-r bg-white shadow-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l bg-white shadow-sm",
|
||||
column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||
)}
|
||||
|
|
@ -118,15 +118,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||
</span>
|
||||
{column.sortable && (
|
||||
<span className="ml-1">
|
||||
<span className="ml-2 flex h-5 w-5 items-center justify-center rounded-md bg-white/50 shadow-sm">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3 text-blue-600" />
|
||||
<ArrowUp className="h-3.5 w-3.5 text-blue-600" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3 text-blue-600" />
|
||||
<ArrowDown className="h-3.5 w-3.5 text-blue-600" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||||
<ArrowUpDown className="h-3.5 w-3.5 text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -142,8 +142,16 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="py-8 text-center text-gray-500">
|
||||
데이터가 없습니다
|
||||
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200">
|
||||
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500">데이터가 없습니다</span>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-3 py-1 rounded-full">조건을 변경하여 다시 검색해보세요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -151,11 +159,11 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
<TableRow
|
||||
key={`row-${index}`}
|
||||
className={cn(
|
||||
"h-10 cursor-pointer border-b leading-none",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/30",
|
||||
"h-12 cursor-pointer border-b border-gray-100/60 leading-none transition-all duration-200",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/20 hover:shadow-sm",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gradient-to-r from-slate-50/30 to-gray-50/20",
|
||||
)}
|
||||
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }}
|
||||
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
|
|
@ -177,15 +185,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
className={cn(
|
||||
"h-10 px-4 py-2 align-middle text-sm whitespace-nowrap",
|
||||
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap text-slate-600 transition-all duration-200",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "sticky z-10 border-r bg-white",
|
||||
column.fixed === "right" && "sticky z-10 border-l bg-white",
|
||||
column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-white/90 backdrop-blur-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-white/90 backdrop-blur-sm",
|
||||
)}
|
||||
style={{
|
||||
minHeight: "40px",
|
||||
height: "40px",
|
||||
minHeight: "48px",
|
||||
height: "48px",
|
||||
verticalAlign: "middle",
|
||||
width: getColumnWidth(column),
|
||||
boxSizing: "border-box",
|
||||
|
|
|
|||
|
|
@ -50,9 +50,10 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
|
|||
const zoneStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
// 구역 경계 시각화 - 항상 표시
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
||||
border: "1px solid rgba(226, 232, 240, 0.6)",
|
||||
borderRadius: "12px",
|
||||
backgroundColor: "rgba(248, 250, 252, 0.3)",
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
|
||||
transition: "all 0.2s ease",
|
||||
...this.getZoneStyle(zone),
|
||||
...additionalProps.style,
|
||||
|
|
@ -62,19 +63,21 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
|
|||
if (isDesignMode) {
|
||||
// 🎯 컴포넌트가 있는 존은 테두리 제거 (컴포넌트 자체 테두리와 충돌 방지)
|
||||
if (zoneChildren.length === 0) {
|
||||
zoneStyle.border = "2px dashed #cbd5e1";
|
||||
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
||||
zoneStyle.border = "2px dashed rgba(203, 213, 225, 0.6)";
|
||||
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.5)";
|
||||
zoneStyle.borderRadius = "12px";
|
||||
} else {
|
||||
// 컴포넌트가 있는 존은 미묘한 배경만
|
||||
zoneStyle.border = "1px solid transparent";
|
||||
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.3)";
|
||||
zoneStyle.border = "1px solid rgba(226, 232, 240, 0.3)";
|
||||
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.2)";
|
||||
zoneStyle.borderRadius = "12px";
|
||||
}
|
||||
}
|
||||
|
||||
// 호버 효과를 위한 추가 스타일
|
||||
const dropZoneStyle: React.CSSProperties = {
|
||||
minHeight: isDesignMode ? "60px" : "40px",
|
||||
borderRadius: "4px",
|
||||
minHeight: isDesignMode ? "80px" : "50px",
|
||||
borderRadius: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
||||
|
|
|
|||
Loading…
Reference in New Issue