Merge pull request 'feature/screen-management' (#106) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/106
This commit is contained in:
commit
6603ff81fe
|
|
@ -0,0 +1,998 @@
|
|||
# 반응형 레이아웃 시스템 구현 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
화면 디자이너는 절대 위치 기반으로 유지하되, 실제 화면 표시는 반응형으로 동작하도록 전환
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
- ✅ 화면 디자이너의 절대 위치 기반 드래그앤드롭은 그대로 유지
|
||||
- ✅ 실제 화면 표시만 반응형으로 전환
|
||||
- ✅ 데이터 마이그레이션 불필요 (신규 화면부터 적용)
|
||||
- ✅ 기존 화면은 불러올 때 스마트 기본값 자동 생성
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Phase 1: 기본 반응형 시스템 구축 (2-3일)
|
||||
|
||||
### 1.1 타입 정의 (2시간)
|
||||
|
||||
#### 파일: `frontend/types/responsive.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 브레이크포인트 타입 정의
|
||||
*/
|
||||
export type Breakpoint = "desktop" | "tablet" | "mobile";
|
||||
|
||||
/**
|
||||
* 브레이크포인트별 설정
|
||||
*/
|
||||
export interface BreakpointConfig {
|
||||
minWidth: number; // 최소 너비 (px)
|
||||
maxWidth?: number; // 최대 너비 (px)
|
||||
columns: number; // 그리드 컬럼 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 브레이크포인트 설정
|
||||
*/
|
||||
export const BREAKPOINTS: Record<Breakpoint, BreakpointConfig> = {
|
||||
desktop: {
|
||||
minWidth: 1200,
|
||||
columns: 12,
|
||||
},
|
||||
tablet: {
|
||||
minWidth: 768,
|
||||
maxWidth: 1199,
|
||||
columns: 8,
|
||||
},
|
||||
mobile: {
|
||||
minWidth: 0,
|
||||
maxWidth: 767,
|
||||
columns: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 브레이크포인트별 반응형 설정
|
||||
*/
|
||||
export interface ResponsiveBreakpointConfig {
|
||||
gridColumns?: number; // 차지할 컬럼 수 (1-12)
|
||||
order?: number; // 정렬 순서
|
||||
hide?: boolean; // 숨김 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 반응형 설정
|
||||
*/
|
||||
export interface ResponsiveComponentConfig {
|
||||
// 기본값 (디자이너에서 설정한 절대 위치)
|
||||
designerPosition: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// 반응형 설정 (선택적)
|
||||
responsive?: {
|
||||
desktop?: ResponsiveBreakpointConfig;
|
||||
tablet?: ResponsiveBreakpointConfig;
|
||||
mobile?: ResponsiveBreakpointConfig;
|
||||
};
|
||||
|
||||
// 스마트 기본값 사용 여부
|
||||
useSmartDefaults?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 스마트 기본값 생성기 (3시간)
|
||||
|
||||
#### 파일: `frontend/lib/utils/responsiveDefaults.ts`
|
||||
|
||||
```typescript
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
|
||||
|
||||
/**
|
||||
* 컴포넌트 크기에 따른 스마트 기본값 생성
|
||||
*
|
||||
* 로직:
|
||||
* - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지
|
||||
* - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장
|
||||
* - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비
|
||||
*/
|
||||
export function generateSmartDefaults(
|
||||
component: ComponentData,
|
||||
screenWidth: number = 1920
|
||||
): ResponsiveComponentConfig["responsive"] {
|
||||
const componentWidthPercent = (component.size.width / screenWidth) * 100;
|
||||
|
||||
// 작은 컴포넌트 (25% 이하)
|
||||
if (componentWidthPercent <= 25) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 3, // 12컬럼 중 3개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 2, // 8컬럼 중 2개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 1, // 4컬럼 중 1개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 중간 컴포넌트 (25-50%)
|
||||
else if (componentWidthPercent <= 50) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 6, // 12컬럼 중 6개 (50%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 4, // 8컬럼 중 4개 (50%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 4컬럼 전체 (100%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 큰 컴포넌트 (50% 이상)
|
||||
else {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 12, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 8, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트에 반응형 설정이 없을 경우 자동 생성
|
||||
*/
|
||||
export function ensureResponsiveConfig(
|
||||
component: ComponentData,
|
||||
screenWidth?: number
|
||||
): ComponentData {
|
||||
if (component.responsiveConfig) {
|
||||
return component;
|
||||
}
|
||||
|
||||
return {
|
||||
...component,
|
||||
responsiveConfig: {
|
||||
designerPosition: {
|
||||
x: component.position.x,
|
||||
y: component.position.y,
|
||||
width: component.size.width,
|
||||
height: component.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
responsive: generateSmartDefaults(component, screenWidth),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 브레이크포인트 감지 훅 (1시간)
|
||||
|
||||
#### 파일: `frontend/hooks/useBreakpoint.ts`
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from "react";
|
||||
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||
|
||||
/**
|
||||
* 현재 윈도우 크기에 따른 브레이크포인트 반환
|
||||
*/
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop");
|
||||
|
||||
useEffect(() => {
|
||||
const updateBreakpoint = () => {
|
||||
const width = window.innerWidth;
|
||||
|
||||
if (width >= BREAKPOINTS.desktop.minWidth) {
|
||||
setBreakpoint("desktop");
|
||||
} else if (width >= BREAKPOINTS.tablet.minWidth) {
|
||||
setBreakpoint("tablet");
|
||||
} else {
|
||||
setBreakpoint("mobile");
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 실행
|
||||
updateBreakpoint();
|
||||
|
||||
// 리사이즈 이벤트 리스너 등록
|
||||
window.addEventListener("resize", updateBreakpoint);
|
||||
|
||||
return () => window.removeEventListener("resize", updateBreakpoint);
|
||||
}, []);
|
||||
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 브레이크포인트의 컬럼 수 반환
|
||||
*/
|
||||
export function useGridColumns(): number {
|
||||
const breakpoint = useBreakpoint();
|
||||
return BREAKPOINTS[breakpoint].columns;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 반응형 레이아웃 엔진 (6시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ResponsiveLayoutEngine.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useMemo } from "react";
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||
import {
|
||||
generateSmartDefaults,
|
||||
ensureResponsiveConfig,
|
||||
} from "@/lib/utils/responsiveDefaults";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
interface ResponsiveLayoutEngineProps {
|
||||
components: ComponentData[];
|
||||
breakpoint: Breakpoint;
|
||||
containerWidth: number;
|
||||
screenWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응형 레이아웃 엔진
|
||||
*
|
||||
* 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환
|
||||
*
|
||||
* 변환 로직:
|
||||
* 1. Y 위치 기준으로 행(row)으로 그룹화
|
||||
* 2. 각 행 내에서 X 위치 기준으로 정렬
|
||||
* 3. 반응형 설정 적용 (order, gridColumns, hide)
|
||||
* 4. CSS Grid로 렌더링
|
||||
*/
|
||||
export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
|
||||
components,
|
||||
breakpoint,
|
||||
containerWidth,
|
||||
screenWidth = 1920,
|
||||
}) => {
|
||||
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
|
||||
const rows = useMemo(() => {
|
||||
const sortedComponents = [...components].sort(
|
||||
(a, b) => a.position.y - b.position.y
|
||||
);
|
||||
|
||||
const rows: ComponentData[][] = [];
|
||||
let currentRow: ComponentData[] = [];
|
||||
let currentRowY = 0;
|
||||
const ROW_THRESHOLD = 50; // 같은 행으로 간주할 Y 오차 범위 (px)
|
||||
|
||||
sortedComponents.forEach((comp) => {
|
||||
if (currentRow.length === 0) {
|
||||
currentRow.push(comp);
|
||||
currentRowY = comp.position.y;
|
||||
} else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) {
|
||||
currentRow.push(comp);
|
||||
} else {
|
||||
rows.push(currentRow);
|
||||
currentRow = [comp];
|
||||
currentRowY = comp.position.y;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [components]);
|
||||
|
||||
// 2단계: 각 행 내에서 X 위치 기준으로 정렬
|
||||
const sortedRows = useMemo(() => {
|
||||
return rows.map((row) =>
|
||||
[...row].sort((a, b) => a.position.x - b.position.x)
|
||||
);
|
||||
}, [rows]);
|
||||
|
||||
// 3단계: 반응형 설정 적용
|
||||
const responsiveComponents = useMemo(() => {
|
||||
return sortedRows.flatMap((row) =>
|
||||
row.map((comp) => {
|
||||
// 반응형 설정이 없으면 자동 생성
|
||||
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
|
||||
|
||||
// 현재 브레이크포인트의 설정 가져오기
|
||||
const config = compWithConfig.responsiveConfig!.useSmartDefaults
|
||||
? generateSmartDefaults(comp, screenWidth)[breakpoint]
|
||||
: compWithConfig.responsiveConfig!.responsive?.[breakpoint];
|
||||
|
||||
return {
|
||||
...compWithConfig,
|
||||
responsiveDisplay:
|
||||
config || generateSmartDefaults(comp, screenWidth)[breakpoint],
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [sortedRows, breakpoint, screenWidth]);
|
||||
|
||||
// 4단계: 필터링 및 정렬
|
||||
const visibleComponents = useMemo(() => {
|
||||
return responsiveComponents
|
||||
.filter((comp) => !comp.responsiveDisplay?.hide)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0)
|
||||
);
|
||||
}, [responsiveComponents]);
|
||||
|
||||
const gridColumns = BREAKPOINTS[breakpoint].columns;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="responsive-grid w-full"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||
gap: "16px",
|
||||
padding: "16px",
|
||||
}}
|
||||
>
|
||||
{visibleComponents.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="responsive-grid-item"
|
||||
style={{
|
||||
gridColumn: `span ${
|
||||
comp.responsiveDisplay?.gridColumns || gridColumns
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer component={comp} isPreview={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 1.5 화면 표시 페이지 수정 (4시간)
|
||||
|
||||
#### 파일: `frontend/app/(main)/screens/[screenId]/page.tsx`
|
||||
|
||||
```typescript
|
||||
// 기존 import 유지
|
||||
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
|
||||
export default function ScreenViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: { screenId: string };
|
||||
}) {
|
||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
// 반응형 모드 토글 (사용자 설정 또는 화면 설정에 따라)
|
||||
const [useResponsive, setUseResponsive] = useState(true);
|
||||
|
||||
// 기존 로직 유지...
|
||||
|
||||
if (!layout) {
|
||||
return <div>로딩 중...</div>;
|
||||
}
|
||||
|
||||
const screenWidth = layout.screenResolution?.width || 1920;
|
||||
const screenHeight = layout.screenResolution?.height || 1080;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-white">
|
||||
{useResponsive ? (
|
||||
// 반응형 모드
|
||||
<ResponsiveLayoutEngine
|
||||
components={layout.components || []}
|
||||
breakpoint={breakpoint}
|
||||
containerWidth={window.innerWidth}
|
||||
screenWidth={screenWidth}
|
||||
/>
|
||||
) : (
|
||||
// 기존 스케일 모드 (하위 호환성)
|
||||
<div className="overflow-auto" style={{ padding: "16px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${screenWidth * scale}px`,
|
||||
minHeight: `${screenHeight * scale}px`,
|
||||
marginLeft: "16px",
|
||||
marginRight: "16px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
{layout.components?.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width:
|
||||
component.style?.width || `${component.size.width}px`,
|
||||
minHeight:
|
||||
component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isPreview={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Phase 2: 디자이너 통합 (1-2일)
|
||||
|
||||
### 2.1 반응형 설정 패널 (5시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/panels/ResponsiveConfigPanel.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useState } from "react";
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import {
|
||||
Breakpoint,
|
||||
BREAKPOINTS,
|
||||
ResponsiveComponentConfig,
|
||||
} from "@/types/responsive";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface ResponsiveConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdate: (config: ResponsiveComponentConfig) => void;
|
||||
}
|
||||
|
||||
export const ResponsiveConfigPanel: React.FC<ResponsiveConfigPanelProps> = ({
|
||||
component,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<Breakpoint>("desktop");
|
||||
|
||||
const config = component.responsiveConfig || {
|
||||
designerPosition: {
|
||||
x: component.position.x,
|
||||
y: component.position.y,
|
||||
width: component.size.width,
|
||||
height: component.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>반응형 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 스마트 기본값 토글 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="smartDefaults"
|
||||
checked={config.useSmartDefaults}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
useSmartDefaults: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="smartDefaults">스마트 기본값 사용 (권장)</Label>
|
||||
</div>
|
||||
|
||||
{/* 수동 설정 */}
|
||||
{!config.useSmartDefaults && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as Breakpoint)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="desktop">데스크톱</TabsTrigger>
|
||||
<TabsTrigger value="tablet">태블릿</TabsTrigger>
|
||||
<TabsTrigger value="mobile">모바일</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="space-y-4">
|
||||
{/* 그리드 컬럼 수 */}
|
||||
<div className="space-y-2">
|
||||
<Label>너비 (그리드 컬럼)</Label>
|
||||
<Select
|
||||
value={config.responsive?.[
|
||||
activeTab
|
||||
]?.gridColumns?.toString()}
|
||||
onValueChange={(v) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
gridColumns: parseInt(v),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="컬럼 수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...Array(BREAKPOINTS[activeTab].columns)].map((_, i) => {
|
||||
const cols = i + 1;
|
||||
const percent = (
|
||||
(cols / BREAKPOINTS[activeTab].columns) *
|
||||
100
|
||||
).toFixed(0);
|
||||
return (
|
||||
<SelectItem key={cols} value={cols.toString()}>
|
||||
{cols} / {BREAKPOINTS[activeTab].columns} ({percent}%)
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label>표시 순서</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.responsive?.[activeTab]?.order || 1}
|
||||
onChange={(e) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
order: parseInt(e.target.value),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숨김 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`hide-${activeTab}`}
|
||||
checked={config.responsive?.[activeTab]?.hide || false}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
hide: checked as boolean,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`hide-${activeTab}`}>
|
||||
{activeTab === "desktop"
|
||||
? "데스크톱"
|
||||
: activeTab === "tablet"
|
||||
? "태블릿"
|
||||
: "모바일"}
|
||||
에서 숨김
|
||||
</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 속성 패널 통합 (1시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
|
||||
|
||||
```typescript
|
||||
// 기존 import에 추가
|
||||
import { ResponsiveConfigPanel } from './ResponsiveConfigPanel';
|
||||
|
||||
// 컴포넌트 내부에 추가
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 기존 패널들 */}
|
||||
<PropertiesPanel ... />
|
||||
<StyleEditor ... />
|
||||
|
||||
{/* 반응형 설정 패널 추가 */}
|
||||
<ResponsiveConfigPanel
|
||||
component={selectedComponent}
|
||||
onUpdate={(config) => {
|
||||
onUpdateComponent({
|
||||
...selectedComponent,
|
||||
responsiveConfig: config
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 기존 세부 설정 패널 */}
|
||||
<DetailSettingsPanel ... />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 2.3 미리보기 모드 (3시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
```typescript
|
||||
// 추가 import
|
||||
import { Breakpoint } from '@/types/responsive';
|
||||
import { ResponsiveLayoutEngine } from './ResponsiveLayoutEngine';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export const ScreenDesigner: React.FC = () => {
|
||||
// 미리보기 모드: 'design' | 'desktop' | 'tablet' | 'mobile'
|
||||
const [previewMode, setPreviewMode] = useState<'design' | Breakpoint>('design');
|
||||
const currentBreakpoint = useBreakpoint();
|
||||
|
||||
// ... 기존 로직 ...
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 상단 툴바 */}
|
||||
<div className="flex gap-2 p-2 border-b bg-white">
|
||||
<Button
|
||||
variant={previewMode === 'design' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('design')}
|
||||
>
|
||||
디자인 모드
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'desktop' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('desktop')}
|
||||
>
|
||||
데스크톱 미리보기
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'tablet' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('tablet')}
|
||||
>
|
||||
태블릿 미리보기
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'mobile' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('mobile')}
|
||||
>
|
||||
모바일 미리보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 캔버스 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{previewMode === 'design' ? (
|
||||
// 기존 절대 위치 기반 디자이너
|
||||
<Canvas ... />
|
||||
) : (
|
||||
// 반응형 미리보기
|
||||
<div
|
||||
className="mx-auto border border-gray-300"
|
||||
style={{
|
||||
width: previewMode === 'desktop' ? '100%' :
|
||||
previewMode === 'tablet' ? '768px' :
|
||||
'375px',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
>
|
||||
<ResponsiveLayoutEngine
|
||||
components={components}
|
||||
breakpoint={previewMode}
|
||||
containerWidth={
|
||||
previewMode === 'desktop' ? window.innerWidth :
|
||||
previewMode === 'tablet' ? 768 :
|
||||
375
|
||||
}
|
||||
screenWidth={selectedScreen?.screenResolution?.width || 1920}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Phase 3: 저장/불러오기 (1일)
|
||||
|
||||
### 3.1 타입 업데이트 (2시간)
|
||||
|
||||
#### 파일: `frontend/types/screen-management.ts` 수정
|
||||
|
||||
```typescript
|
||||
import { ResponsiveComponentConfig } from "./responsive";
|
||||
|
||||
export interface ComponentData {
|
||||
// ... 기존 필드들 ...
|
||||
|
||||
// 반응형 설정 추가
|
||||
responsiveConfig?: ResponsiveComponentConfig;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 저장 로직 (2시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
```typescript
|
||||
// 저장 함수 수정
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const layoutData: LayoutData = {
|
||||
screenResolution: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
components: components.map((comp) => ({
|
||||
...comp,
|
||||
// 반응형 설정이 없으면 자동 생성
|
||||
responsiveConfig: comp.responsiveConfig || {
|
||||
designerPosition: {
|
||||
x: comp.position.x,
|
||||
y: comp.position.y,
|
||||
width: comp.size.width,
|
||||
height: comp.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
await screenApi.updateLayout(selectedScreen.id, layoutData);
|
||||
// ... 기존 로직 ...
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 불러오기 로직 (2시간)
|
||||
|
||||
#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
```typescript
|
||||
import { ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
|
||||
|
||||
// 화면 불러오기
|
||||
useEffect(() => {
|
||||
const loadScreen = async () => {
|
||||
if (!selectedScreenId) return;
|
||||
|
||||
const screen = await screenApi.getScreenById(selectedScreenId);
|
||||
const layout = await screenApi.getLayout(selectedScreenId);
|
||||
|
||||
// 반응형 설정이 없는 컴포넌트에 자동 생성
|
||||
const componentsWithResponsive = layout.components.map((comp) =>
|
||||
ensureResponsiveConfig(comp, layout.screenResolution?.width)
|
||||
);
|
||||
|
||||
setSelectedScreen(screen);
|
||||
setComponents(componentsWithResponsive);
|
||||
};
|
||||
|
||||
loadScreen();
|
||||
}, [selectedScreenId]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Phase 4: 테스트 및 최적화 (1일)
|
||||
|
||||
### 4.1 기능 테스트 체크리스트 (3시간)
|
||||
|
||||
- [ ] 브레이크포인트 전환 테스트
|
||||
- [ ] 윈도우 크기 변경 시 자동 전환
|
||||
- [ ] desktop → tablet → mobile 순차 테스트
|
||||
- [ ] 스마트 기본값 생성 테스트
|
||||
- [ ] 작은 컴포넌트 (25% 이하)
|
||||
- [ ] 중간 컴포넌트 (25-50%)
|
||||
- [ ] 큰 컴포넌트 (50% 이상)
|
||||
- [ ] 수동 설정 적용 테스트
|
||||
- [ ] 그리드 컬럼 변경
|
||||
- [ ] 표시 순서 변경
|
||||
- [ ] 디바이스별 숨김
|
||||
- [ ] 미리보기 모드 테스트
|
||||
- [ ] 디자인 모드 ↔ 미리보기 모드 전환
|
||||
- [ ] 각 브레이크포인트 미리보기
|
||||
- [ ] 저장/불러오기 테스트
|
||||
- [ ] 반응형 설정 저장
|
||||
- [ ] 기존 화면 불러오기 시 자동 변환
|
||||
|
||||
### 4.2 성능 최적화 (3시간)
|
||||
|
||||
#### 레이아웃 계산 메모이제이션
|
||||
|
||||
```typescript
|
||||
// ResponsiveLayoutEngine.tsx
|
||||
const memoizedLayout = useMemo(() => {
|
||||
// 레이아웃 계산 로직
|
||||
}, [components, breakpoint, screenWidth]);
|
||||
```
|
||||
|
||||
#### ResizeObserver 최적화
|
||||
|
||||
```typescript
|
||||
// useBreakpoint.ts
|
||||
// debounce 적용
|
||||
const debouncedResize = debounce(updateBreakpoint, 150);
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
```
|
||||
|
||||
#### 불필요한 리렌더링 방지
|
||||
|
||||
```typescript
|
||||
// React.memo 적용
|
||||
export const ResponsiveLayoutEngine = React.memo<ResponsiveLayoutEngineProps>(({...}) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### 4.3 UI/UX 개선 (2시간)
|
||||
|
||||
- [ ] 반응형 설정 패널 툴팁 추가
|
||||
- [ ] 미리보기 모드 전환 애니메이션
|
||||
- [ ] 로딩 상태 표시
|
||||
- [ ] 에러 처리 및 사용자 피드백
|
||||
|
||||
---
|
||||
|
||||
## 📅 최종 타임라인
|
||||
|
||||
| Phase | 작업 내용 | 소요 시간 | 누적 시간 |
|
||||
| ------- | --------------------- | --------- | ------------ |
|
||||
| Phase 1 | 타입 정의 및 유틸리티 | 6시간 | 6시간 |
|
||||
| Phase 1 | 반응형 레이아웃 엔진 | 6시간 | 12시간 |
|
||||
| Phase 1 | 화면 표시 페이지 수정 | 4시간 | 16시간 (2일) |
|
||||
| Phase 2 | 반응형 설정 패널 | 5시간 | 21시간 |
|
||||
| Phase 2 | 디자이너 통합 | 4시간 | 25시간 (3일) |
|
||||
| Phase 3 | 저장/불러오기 | 6시간 | 31시간 (4일) |
|
||||
| Phase 4 | 테스트 및 최적화 | 8시간 | 39시간 (5일) |
|
||||
|
||||
**총 예상 시간: 39시간 (약 5일)**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 구현 우선순위
|
||||
|
||||
### 1단계: 핵심 기능 (필수)
|
||||
|
||||
1. ✅ 타입 정의
|
||||
2. ✅ 스마트 기본값 생성기
|
||||
3. ✅ 브레이크포인트 훅
|
||||
4. ✅ 반응형 레이아웃 엔진
|
||||
5. ✅ 화면 표시 페이지 수정
|
||||
|
||||
### 2단계: 디자이너 UI (중요)
|
||||
|
||||
6. ✅ 반응형 설정 패널
|
||||
7. ✅ 속성 패널 통합
|
||||
8. ✅ 미리보기 모드
|
||||
|
||||
### 3단계: 데이터 처리 (중요)
|
||||
|
||||
9. ✅ 타입 업데이트
|
||||
10. ✅ 저장/불러오기 로직
|
||||
|
||||
### 4단계: 완성도 (선택)
|
||||
|
||||
11. 테스트
|
||||
12. 최적화
|
||||
13. UI/UX 개선
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 체크리스트
|
||||
|
||||
### Phase 1: 기본 시스템
|
||||
|
||||
- [ ] `frontend/types/responsive.ts` 생성
|
||||
- [ ] `frontend/lib/utils/responsiveDefaults.ts` 생성
|
||||
- [ ] `frontend/hooks/useBreakpoint.ts` 생성
|
||||
- [ ] `frontend/components/screen/ResponsiveLayoutEngine.tsx` 생성
|
||||
- [ ] `frontend/app/(main)/screens/[screenId]/page.tsx` 수정
|
||||
|
||||
### Phase 2: 디자이너 통합
|
||||
|
||||
- [ ] `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` 생성
|
||||
- [ ] `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정
|
||||
- [ ] `frontend/components/screen/ScreenDesigner.tsx` 수정
|
||||
|
||||
### Phase 3: 데이터 처리
|
||||
|
||||
- [ ] `frontend/types/screen-management.ts` 수정
|
||||
- [ ] 저장 로직 수정
|
||||
- [ ] 불러오기 로직 수정
|
||||
|
||||
### Phase 4: 테스트
|
||||
|
||||
- [ ] 기능 테스트 완료
|
||||
- [ ] 성능 최적화 완료
|
||||
- [ ] UI/UX 개선 완료
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작 준비 완료
|
||||
|
||||
이제 Phase 1부터 순차적으로 구현을 시작합니다.
|
||||
|
|
@ -515,6 +515,7 @@ export class DashboardController {
|
|||
});
|
||||
|
||||
// 외부 API 호출
|
||||
// @ts-ignore - node-fetch dynamic import
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
|
|
|
|||
|
|
@ -104,6 +104,30 @@ export const updateScreen = async (
|
|||
}
|
||||
};
|
||||
|
||||
// 화면 정보 수정 (메타데이터만)
|
||||
export const updateScreenInfo = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { screenName, description, isActive } = req.body;
|
||||
|
||||
await screenManagementService.updateScreenInfo(
|
||||
parseInt(id),
|
||||
{ screenName, description, isActive },
|
||||
companyCode
|
||||
);
|
||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("화면 정보 수정 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "화면 정보 수정에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 의존성 체크
|
||||
export const checkScreenDependencies = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,92 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||
*/
|
||||
router.get(
|
||||
"/join",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue } =
|
||||
req.query;
|
||||
|
||||
// 입력값 검증
|
||||
if (!leftTable || !rightTable || !leftColumn || !rightColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 파라미터가 누락되었습니다 (leftTable, rightTable, leftColumn, rightColumn).",
|
||||
error: "MISSING_PARAMETERS",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 검증
|
||||
const tables = [leftTable as string, rightTable as string];
|
||||
const columns = [leftColumn as string, rightColumn as string];
|
||||
|
||||
for (const table of tables) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 테이블명입니다: ${table}`,
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(column)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 컬럼명입니다: ${column}`,
|
||||
error: "INVALID_COLUMN_NAME",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔗 조인 데이터 조회:`, {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
});
|
||||
|
||||
// 조인 데이터 조회
|
||||
const result = await dataService.getJoinedData(
|
||||
leftTable as string,
|
||||
rightTable as string,
|
||||
leftColumn as string,
|
||||
rightColumn as string,
|
||||
leftValue as string
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("조인 데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "조인 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 동적 테이블 데이터 조회 API
|
||||
* GET /api/data/{tableName}
|
||||
|
|
@ -15,7 +101,18 @@ router.get(
|
|||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { limit = "10", offset = "0", orderBy, ...filters } = req.query;
|
||||
const {
|
||||
limit,
|
||||
offset,
|
||||
page,
|
||||
size,
|
||||
orderBy,
|
||||
searchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
userLang,
|
||||
...filters
|
||||
} = req.query;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
|
|
@ -35,21 +132,43 @@ router.get(
|
|||
});
|
||||
}
|
||||
|
||||
// page/size 또는 limit/offset 방식 지원
|
||||
let finalLimit = 100;
|
||||
let finalOffset = 0;
|
||||
|
||||
if (page && size) {
|
||||
// page/size 방식
|
||||
const pageNum = parseInt(page as string) || 1;
|
||||
const sizeNum = parseInt(size as string) || 100;
|
||||
finalLimit = sizeNum;
|
||||
finalOffset = (pageNum - 1) * sizeNum;
|
||||
} else if (limit || offset) {
|
||||
// limit/offset 방식
|
||||
finalLimit = parseInt(limit as string) || 10;
|
||||
finalOffset = parseInt(offset as string) || 0;
|
||||
}
|
||||
|
||||
console.log(`📊 데이터 조회 요청: ${tableName}`, {
|
||||
limit: parseInt(limit as string),
|
||||
offset: parseInt(offset as string),
|
||||
orderBy: orderBy as string,
|
||||
limit: finalLimit,
|
||||
offset: finalOffset,
|
||||
orderBy: orderBy || sortBy,
|
||||
searchTerm,
|
||||
filters,
|
||||
user: req.user?.userId,
|
||||
});
|
||||
|
||||
// filters에서 searchTerm과 sortOrder 제거 (이미 별도로 처리됨)
|
||||
const cleanFilters = { ...filters };
|
||||
delete cleanFilters.searchTerm;
|
||||
delete cleanFilters.sortOrder;
|
||||
|
||||
// 데이터 조회
|
||||
const result = await dataService.getTableData({
|
||||
tableName,
|
||||
limit: parseInt(limit as string),
|
||||
offset: parseInt(offset as string),
|
||||
orderBy: orderBy as string,
|
||||
filters: filters as Record<string, string>,
|
||||
limit: finalLimit,
|
||||
offset: finalOffset,
|
||||
orderBy: (orderBy || sortBy) as string,
|
||||
filters: cleanFilters as Record<string, string>,
|
||||
userCompany: req.user?.companyCode,
|
||||
});
|
||||
|
||||
|
|
@ -61,7 +180,21 @@ router.get(
|
|||
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
|
||||
);
|
||||
|
||||
return res.json(result.data);
|
||||
// 페이징 정보 포함하여 반환
|
||||
const total = result.data?.length || 0;
|
||||
const responsePage =
|
||||
finalLimit > 0 ? Math.floor(finalOffset / finalLimit) + 1 : 1;
|
||||
const responseSize = finalLimit;
|
||||
const totalPages = responseSize > 0 ? Math.ceil(total / responseSize) : 1;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total,
|
||||
page: responsePage,
|
||||
size: responseSize,
|
||||
totalPages,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
|
|
@ -127,4 +260,231 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회 API
|
||||
* GET /api/data/{tableName}/{id}
|
||||
*/
|
||||
router.get(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, id } = req.params;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`);
|
||||
|
||||
// 레코드 상세 조회
|
||||
const result = await dataService.getRecordDetail(tableName, id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 조회 성공: ${tableName}/${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 생성 API
|
||||
* POST /api/data/{tableName}
|
||||
*/
|
||||
router.post(
|
||||
"/:tableName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`➕ 레코드 생성: ${tableName}`, data);
|
||||
|
||||
// 레코드 생성
|
||||
const result = await dataService.createRecord(tableName, data);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 생성 성공: ${tableName}`);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: "레코드가 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 수정 API
|
||||
* PUT /api/data/{tableName}/{id}
|
||||
*/
|
||||
router.put(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
|
||||
|
||||
// 레코드 수정
|
||||
const result = await dataService.updateRecord(tableName, id, data);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: "레코드가 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 삭제 API
|
||||
* DELETE /api/data/{tableName}/{id}
|
||||
*/
|
||||
router.delete(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, id } = req.params;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 레코드 삭제: ${tableName}/${id}`);
|
||||
|
||||
// 레코드 삭제
|
||||
const result = await dataService.deleteRecord(tableName, id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레코드가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getScreen,
|
||||
createScreen,
|
||||
updateScreen,
|
||||
updateScreenInfo,
|
||||
deleteScreen,
|
||||
checkScreenDependencies,
|
||||
restoreScreen,
|
||||
|
|
@ -34,6 +35,7 @@ router.get("/screens", getScreens);
|
|||
router.get("/screens/:id", getScreen);
|
||||
router.post("/screens", createScreen);
|
||||
router.put("/screens/:id", updateScreen);
|
||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||
router.post("/screens/:id/copy", copyScreen);
|
||||
|
|
|
|||
|
|
@ -313,6 +313,283 @@ class DataService {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회
|
||||
*/
|
||||
async getRecordDetail(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id"; // 기본값
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
const result = await query<any>(queryText, [id]);
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
*/
|
||||
async getJoinedData(
|
||||
leftTable: string,
|
||||
rightTable: string,
|
||||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftValue?: string | number
|
||||
): Promise<ServiceResponse<any[]>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(leftTable)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ALLOWED_TABLES.includes(rightTable)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
let queryText = `
|
||||
SELECT r.*
|
||||
FROM "${rightTable}" r
|
||||
INNER JOIN "${leftTable}" l
|
||||
ON l."${leftColumn}" = r."${rightColumn}"
|
||||
`;
|
||||
|
||||
const values: any[] = [];
|
||||
if (leftValue !== undefined && leftValue !== null) {
|
||||
queryText += ` WHERE l."${leftColumn}" = $1`;
|
||||
values.push(leftValue);
|
||||
}
|
||||
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`조인 데이터 조회 오류 (${leftTable} → ${rightTable}):`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: "조인 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 생성
|
||||
*/
|
||||
async createRecord(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||
|
||||
const queryText = `
|
||||
INSERT INTO "${tableName}" (${columnNames})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 생성 오류 (${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 수정
|
||||
*/
|
||||
async updateRecord(
|
||||
tableName: string,
|
||||
id: string | number,
|
||||
data: Record<string, any>
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id";
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const setClause = columns
|
||||
.map((col, index) => `"${col}" = $${index + 1}`)
|
||||
.join(", ");
|
||||
|
||||
const queryText = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${setClause}
|
||||
WHERE "${pkColumn}" = $${values.length + 1}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
values.push(id);
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 수정 오류 (${tableName}/${id}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 삭제
|
||||
*/
|
||||
async deleteRecord(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
): Promise<ServiceResponse<void>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id";
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await query<any>(queryText, [id]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dataService = new DataService();
|
||||
|
|
|
|||
|
|
@ -295,6 +295,54 @@ export class DynamicFormService {
|
|||
}
|
||||
});
|
||||
|
||||
// 📝 RepeaterInput 데이터 처리 (JSON 배열을 개별 레코드로 분해)
|
||||
const repeaterData: Array<{
|
||||
data: Record<string, any>[];
|
||||
targetTable?: string;
|
||||
componentId: string;
|
||||
}> = [];
|
||||
Object.keys(dataToInsert).forEach((key) => {
|
||||
const value = dataToInsert[key];
|
||||
|
||||
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim().startsWith("[") &&
|
||||
value.trim().endsWith("]")
|
||||
) {
|
||||
try {
|
||||
const parsedArray = JSON.parse(value);
|
||||
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||||
);
|
||||
|
||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||
let targetTable: string | undefined;
|
||||
let actualData = parsedArray;
|
||||
|
||||
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
||||
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
||||
targetTable = parsedArray[0]._targetTable;
|
||||
actualData = parsedArray.map(
|
||||
({ _targetTable, ...item }) => item
|
||||
);
|
||||
}
|
||||
|
||||
repeaterData.push({
|
||||
data: actualData,
|
||||
targetTable,
|
||||
componentId: key,
|
||||
});
|
||||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 존재하지 않는 컬럼 제거
|
||||
Object.keys(dataToInsert).forEach((key) => {
|
||||
if (!tableColumns.includes(key)) {
|
||||
|
|
@ -305,6 +353,9 @@ export class DynamicFormService {
|
|||
}
|
||||
});
|
||||
|
||||
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
|
||||
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
|
||||
|
||||
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
||||
tableName,
|
||||
dataToInsert,
|
||||
|
|
@ -388,6 +439,111 @@ export class DynamicFormService {
|
|||
// 결과를 표준 형식으로 변환
|
||||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
|
||||
if (repeaterData.length > 0) {
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater`
|
||||
);
|
||||
|
||||
for (const repeater of repeaterData) {
|
||||
const targetTableName = repeater.targetTable || tableName;
|
||||
console.log(
|
||||
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
||||
);
|
||||
|
||||
// 대상 테이블의 컬럼 및 기본키 정보 조회
|
||||
const targetTableColumns =
|
||||
await this.getTableColumns(targetTableName);
|
||||
const targetPrimaryKeys = await this.getPrimaryKeys(targetTableName);
|
||||
|
||||
// 컬럼명만 추출
|
||||
const targetColumnNames = targetTableColumns.map(
|
||||
(col) => col.columnName
|
||||
);
|
||||
|
||||
// 각 항목을 저장
|
||||
for (let i = 0; i < repeater.data.length; i++) {
|
||||
const item = repeater.data[i];
|
||||
const itemData: Record<string, any> = {
|
||||
...item,
|
||||
created_by,
|
||||
updated_by,
|
||||
regdate: new Date(),
|
||||
};
|
||||
|
||||
// 대상 테이블에 존재하는 컬럼만 필터링
|
||||
Object.keys(itemData).forEach((key) => {
|
||||
if (!targetColumnNames.includes(key)) {
|
||||
delete itemData[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 타입 변환 적용
|
||||
Object.keys(itemData).forEach((columnName) => {
|
||||
const column = targetTableColumns.find(
|
||||
(col) => col.columnName === columnName
|
||||
);
|
||||
if (column) {
|
||||
itemData[columnName] = this.convertValueForPostgreSQL(
|
||||
itemData[columnName],
|
||||
column.dataType
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// UPSERT 쿼리 생성
|
||||
const itemColumns = Object.keys(itemData);
|
||||
const itemValues: any[] = Object.values(itemData);
|
||||
const itemPlaceholders = itemValues
|
||||
.map((_, index) => `$${index + 1}`)
|
||||
.join(", ");
|
||||
|
||||
let itemUpsertQuery: string;
|
||||
if (targetPrimaryKeys.length > 0) {
|
||||
const conflictColumns = targetPrimaryKeys.join(", ");
|
||||
const updateSet = itemColumns
|
||||
.filter((col) => !targetPrimaryKeys.includes(col))
|
||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||
.join(", ");
|
||||
|
||||
if (updateSet) {
|
||||
itemUpsertQuery = `
|
||||
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
|
||||
VALUES (${itemPlaceholders})
|
||||
ON CONFLICT (${conflictColumns})
|
||||
DO UPDATE SET ${updateSet}
|
||||
RETURNING *
|
||||
`;
|
||||
} else {
|
||||
itemUpsertQuery = `
|
||||
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
|
||||
VALUES (${itemPlaceholders})
|
||||
ON CONFLICT (${conflictColumns})
|
||||
DO NOTHING
|
||||
RETURNING *
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
itemUpsertQuery = `
|
||||
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
|
||||
VALUES (${itemPlaceholders})
|
||||
RETURNING *
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(
|
||||
` 📝 항목 ${i + 1}/${repeater.data.length} 저장:`,
|
||||
itemData
|
||||
);
|
||||
await query<any>(itemUpsertQuery, itemValues);
|
||||
}
|
||||
|
||||
console.log(` ✅ Repeater "${repeater.componentId}" 저장 완료`);
|
||||
}
|
||||
|
||||
console.log(`✅ 모든 RepeaterInput 데이터 저장 완료`);
|
||||
}
|
||||
|
||||
// 🔥 조건부 연결 실행 (INSERT 트리거)
|
||||
try {
|
||||
if (company_code) {
|
||||
|
|
@ -1114,6 +1270,31 @@ export class DynamicFormService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 기본키 컬럼명 목록 조회
|
||||
*/
|
||||
async getPrimaryKeys(tableName: string): Promise<string[]> {
|
||||
try {
|
||||
console.log("🔑 서비스: 테이블 기본키 조회 시작:", { tableName });
|
||||
|
||||
const result = await query<{ column_name: string }>(
|
||||
`SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
const primaryKeys = result.map((row) => row.column_name);
|
||||
console.log("✅ 서비스: 테이블 기본키 조회 성공:", primaryKeys);
|
||||
|
||||
return primaryKeys;
|
||||
} catch (error) {
|
||||
console.error("❌ 서비스: 테이블 기본키 조회 실패:", error);
|
||||
throw new Error(`테이블 기본키 조회 실패: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어관리 실행 (화면에 설정된 경우)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -219,7 +219,11 @@ export class EntityJoinService {
|
|||
];
|
||||
const separator = config.separator || " - ";
|
||||
|
||||
if (displayColumns.length === 1) {
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||
return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
|
||||
} else if (displayColumns.length === 1) {
|
||||
// 단일 컬럼인 경우
|
||||
const col = displayColumns[0];
|
||||
const isJoinTableColumn = [
|
||||
|
|
|
|||
|
|
@ -300,6 +300,51 @@ export class ScreenManagementService {
|
|||
return this.mapToScreenDefinition(screen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 정보 수정 (메타데이터만) - 편집 기능용
|
||||
*/
|
||||
async updateScreenInfo(
|
||||
screenId: number,
|
||||
updateData: { screenName: string; description?: string; isActive: string },
|
||||
userCompanyCode: string
|
||||
): Promise<void> {
|
||||
// 권한 확인
|
||||
const existingResult = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
if (existingResult.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const existingScreen = existingResult[0];
|
||||
|
||||
if (
|
||||
userCompanyCode !== "*" &&
|
||||
existingScreen.company_code !== userCompanyCode
|
||||
) {
|
||||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 화면 정보 업데이트
|
||||
await query(
|
||||
`UPDATE screen_definitions
|
||||
SET screen_name = $1,
|
||||
description = $2,
|
||||
is_active = $3,
|
||||
updated_date = $4
|
||||
WHERE screen_id = $5`,
|
||||
[
|
||||
updateData.screenName,
|
||||
updateData.description || null,
|
||||
updateData.isActive,
|
||||
new Date(),
|
||||
screenId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -112,9 +112,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
|
|||
json: "textarea",
|
||||
jsonb: "textarea",
|
||||
|
||||
// 배열 타입 (텍스트로 처리)
|
||||
ARRAY: "textarea",
|
||||
|
||||
// UUID 타입
|
||||
uuid: "text",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 제어 시스템 페이지
|
||||
* 제어 시스템 페이지 (리다이렉트)
|
||||
* 이 페이지는 /admin/dataflow로 리다이렉트됩니다.
|
||||
*/
|
||||
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function NodeEditorPage() {
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900">제어 시스템</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
const router = useRouter();
|
||||
|
||||
{/* 에디터 */}
|
||||
<FlowEditor />
|
||||
useEffect(() => {
|
||||
// /admin/dataflow 메인 페이지로 리다이렉트
|
||||
router.replace("/admin/dataflow");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-500">제어 관리 페이지로 이동중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,102 +2,78 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||
// 🎨 새로운 UI 컴포넌트 import
|
||||
import DataConnectionDesigner from "@/components/dataflow/connection/redesigned/DataConnectionDesigner";
|
||||
import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { loadDataflowRelationship } from "@/lib/api/dataflowSave";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
type Step = "list" | "design";
|
||||
type Step = "list" | "editor";
|
||||
|
||||
export default function DataFlowPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
const [editingDiagram, setEditingDiagram] = useState<DataFlowDiagram | null>(null);
|
||||
const [loadedRelationshipData, setLoadedRelationshipData] = useState<any>(null);
|
||||
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
|
||||
|
||||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
list: {
|
||||
title: "데이터 흐름 제어 관리",
|
||||
description: "생성된 제어들을 확인하고 관리하세요",
|
||||
icon: "📊",
|
||||
},
|
||||
design: {
|
||||
title: "새 제어 설계",
|
||||
description: "테이블 간 데이터 제어를 시각적으로 설계하세요",
|
||||
icon: "🎨",
|
||||
},
|
||||
};
|
||||
// 플로우 불러오기 핸들러
|
||||
const handleLoadFlow = async (flowId: number | null) => {
|
||||
if (flowId === null) {
|
||||
// 새 플로우 생성
|
||||
setLoadingFlowId(null);
|
||||
setCurrentStep("editor");
|
||||
return;
|
||||
}
|
||||
|
||||
// 다음 단계로 이동
|
||||
const goToNextStep = (nextStep: Step) => {
|
||||
setStepHistory((prev) => [...prev, nextStep]);
|
||||
setCurrentStep(nextStep);
|
||||
};
|
||||
try {
|
||||
// 기존 플로우 불러오기
|
||||
setLoadingFlowId(flowId);
|
||||
setCurrentStep("editor");
|
||||
|
||||
// 이전 단계로 이동
|
||||
const goToPreviousStep = () => {
|
||||
if (stepHistory.length > 1) {
|
||||
const newHistory = stepHistory.slice(0, -1);
|
||||
const previousStep = newHistory[newHistory.length - 1];
|
||||
setStepHistory(newHistory);
|
||||
setCurrentStep(previousStep);
|
||||
toast.success("플로우를 불러왔습니다.");
|
||||
} catch (error: any) {
|
||||
console.error("❌ 플로우 불러오기 실패:", error);
|
||||
toast.error(error.message || "플로우를 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 단계로 이동
|
||||
const goToStep = (step: Step) => {
|
||||
setCurrentStep(step);
|
||||
// 해당 단계까지의 히스토리만 유지
|
||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||
if (stepIndex !== -1) {
|
||||
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||
}
|
||||
// 목록으로 돌아가기
|
||||
const handleBackToList = () => {
|
||||
setCurrentStep("list");
|
||||
setLoadingFlowId(null);
|
||||
};
|
||||
|
||||
const handleSave = (relationships: TableRelationship[]) => {
|
||||
console.log("저장된 제어:", relationships);
|
||||
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
||||
setTimeout(() => {
|
||||
goToStep("list");
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
}, 0);
|
||||
};
|
||||
// 에디터 모드일 때는 전체 화면 사용
|
||||
const isEditorMode = currentStep === "editor";
|
||||
|
||||
// 제어 수정 핸들러
|
||||
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
|
||||
if (diagram) {
|
||||
// 기존 제어 수정 - 저장된 제어 정보 로드
|
||||
try {
|
||||
console.log("📖 제어 수정 모드:", diagram);
|
||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||
if (isEditorMode) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 에디터 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b bg-white p-4">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">노드 플로우 에디터</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 저장된 제어 정보 로드
|
||||
const relationshipData = await loadDataflowRelationship(diagram.diagramId);
|
||||
console.log("✅ 제어 정보 로드 완료:", relationshipData);
|
||||
|
||||
setEditingDiagram(diagram);
|
||||
setLoadedRelationshipData(relationshipData);
|
||||
goToNextStep("design");
|
||||
|
||||
toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 제어 정보 로드 실패:", error);
|
||||
toast.error(error.message || "제어 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 새 제어 생성 - 현재 페이지에서 처리
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
goToNextStep("design");
|
||||
}
|
||||
};
|
||||
{/* 플로우 에디터 */}
|
||||
<div className="flex-1">
|
||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
|
@ -106,32 +82,12 @@ export default function DataFlowPage() {
|
|||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">제어 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 제어를 시각적으로 설계하고 관리합니다</p>
|
||||
<p className="mt-2 text-gray-600">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="space-y-6">
|
||||
{/* 제어 목록 단계 */}
|
||||
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
|
||||
|
||||
{/* 제어 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{currentStep === "design" && (
|
||||
<DataConnectionDesigner
|
||||
onClose={() => {
|
||||
goToStep("list");
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
}}
|
||||
initialData={
|
||||
loadedRelationshipData || {
|
||||
connectionType: "data_save",
|
||||
}
|
||||
}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 플로우 목록 */}
|
||||
<DataFlowList onLoadFlow={handleLoadFlow} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||||
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
||||
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -25,21 +22,19 @@ export default function ScreenViewPage() {
|
|||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 테이블 선택된 행 상태 (화면 레벨에서 관리)
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||
// 화면 너비에 따라 Y좌표 유지 여부 결정
|
||||
const [preserveYPosition, setPreserveYPosition] = useState(true);
|
||||
|
||||
// 테이블 새로고침을 위한 키 상태
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
screenId?: number;
|
||||
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
editData?: any;
|
||||
editData?: Record<string, unknown>;
|
||||
onSave?: () => void;
|
||||
modalTitle?: string;
|
||||
modalDescription?: string;
|
||||
|
|
@ -75,11 +70,11 @@ export default function ScreenViewPage() {
|
|||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - CustomEvent type
|
||||
window.addEventListener("openEditModal", handleOpenEditModal);
|
||||
|
||||
return () => {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - CustomEvent type
|
||||
window.removeEventListener("openEditModal", handleOpenEditModal);
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -101,8 +96,18 @@ export default function ScreenViewPage() {
|
|||
} catch (layoutError) {
|
||||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||||
setLayout({
|
||||
screenId,
|
||||
components: [],
|
||||
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
||||
gridSettings: {
|
||||
columns: 12,
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
enabled: true,
|
||||
size: 8,
|
||||
color: "#e0e0e0",
|
||||
opacity: 0.5,
|
||||
snapToGrid: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -119,6 +124,24 @@ export default function ScreenViewPage() {
|
|||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행
|
||||
useEffect(() => {
|
||||
if (!layout) return;
|
||||
|
||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||
|
||||
const handleResize = () => {
|
||||
const shouldPreserve = window.innerWidth >= screenWidth - 100;
|
||||
setPreserveYPosition(shouldPreserve);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
// 초기 값도 설정
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [layout]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
|
|
@ -149,264 +172,39 @@ export default function ScreenViewPage() {
|
|||
|
||||
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||
const screenHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 p-10">
|
||||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
className="relative mx-auto rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
|
||||
.map((component) => {
|
||||
// 그룹 컴포넌트인 경우 특별 처리
|
||||
if (component.type === "group") {
|
||||
const groupChildren = layout.components.filter((child) => child.parentId === component.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
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-3 inline-block rounded-lg bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700">
|
||||
{(component as any).title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
||||
{groupChildren.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${child.position.x}px`,
|
||||
top: `${child.position.y}px`,
|
||||
width: child.style?.width || `${child.size.width}px`,
|
||||
height: child.style?.height || `${child.size.height}px`,
|
||||
zIndex: child.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={child}
|
||||
allComponents={layout.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경:", { fieldName, value });
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📊 전체 폼 데이터:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
screenInfo={{
|
||||
id: screenId,
|
||||
tableName: screen?.tableName,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 라벨 표시 여부 계산
|
||||
const templateTypes = ["datatable"];
|
||||
const shouldShowLabel =
|
||||
component.style?.labelDisplay !== false &&
|
||||
(component.label || component.style?.labelText) &&
|
||||
!templateTypes.includes(component.type);
|
||||
|
||||
const labelText = component.style?.labelText || component.label || "";
|
||||
const labelStyle = {
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
padding: component.style?.labelPadding || "0",
|
||||
borderRadius: component.style?.labelBorderRadius || "0",
|
||||
marginBottom: component.style?.labelMarginBottom || "4px",
|
||||
};
|
||||
|
||||
// 일반 컴포넌트 렌더링
|
||||
return (
|
||||
<div key={component.id}>
|
||||
{/* 라벨을 외부에 별도로 렌더링 */}
|
||||
{shouldShowLabel && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
|
||||
zIndex: (component.position.z || 1) + 1,
|
||||
...labelStyle,
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 컴포넌트 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
// console.log("🎯 할당된 화면 컴포넌트:", {
|
||||
// id: component.id,
|
||||
// type: component.type,
|
||||
// position: component.position,
|
||||
// size: component.size,
|
||||
// styleWidth: component.style?.width,
|
||||
// styleHeight: component.style?.height,
|
||||
// finalWidth: `${component.size.width}px`,
|
||||
// finalHeight: `${component.size.height}px`,
|
||||
// });
|
||||
}}
|
||||
>
|
||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
|
||||
},
|
||||
}}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
onRefresh={() => {
|
||||
console.log("화면 새로고침 요청");
|
||||
// 테이블 컴포넌트 강제 새로고침을 위한 키 업데이트
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
// 선택된 행 상태도 초기화
|
||||
setSelectedRows([]);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
onClose={() => {
|
||||
console.log("화면 닫기 요청");
|
||||
}}
|
||||
// 테이블 선택된 행 정보 전달
|
||||
selectedRows={selectedRows}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(newSelectedRows, newSelectedRowsData) => {
|
||||
setSelectedRows(newSelectedRows);
|
||||
setSelectedRowsData(newSelectedRowsData);
|
||||
}}
|
||||
// 테이블 새로고침 키 전달
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
) : (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
originalWebType: component.webType,
|
||||
});
|
||||
return "file";
|
||||
}
|
||||
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
|
||||
return getComponentWebType(component) || "text";
|
||||
})()}
|
||||
config={component.webTypeConfig}
|
||||
props={{
|
||||
component: component,
|
||||
value: formData[component.columnName || component.id] || "",
|
||||
onChange: (value: any) => {
|
||||
const fieldName = component.columnName || component.id;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
},
|
||||
onFormDataChange: (fieldName, value) => {
|
||||
console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
console.log("📋 현재 formData:", formData);
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📝 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
},
|
||||
isInteractive: true,
|
||||
formData: formData,
|
||||
readonly: component.readonly,
|
||||
required: component.required,
|
||||
placeholder: component.placeholder,
|
||||
className: "w-full h-full",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 빈 화면일 때도 깔끔하게 표시
|
||||
<div
|
||||
className="mx-auto flex items-center justify-center rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
||||
<span className="text-2xl">📄</span>
|
||||
<div className="h-full w-full bg-white">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
{/* 항상 반응형 모드로 렌더링 */}
|
||||
{layout && layout.components.length > 0 ? (
|
||||
<ResponsiveLayoutEngine
|
||||
components={layout?.components || []}
|
||||
breakpoint={breakpoint}
|
||||
containerWidth={window.innerWidth}
|
||||
screenWidth={screenWidth}
|
||||
preserveYPosition={preserveYPosition}
|
||||
isDesignMode={false}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName: string, value: unknown) => {
|
||||
console.log("📝 page.tsx formData 업데이트:", fieldName, value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
screenInfo={{ id: screenId, tableName: screen?.tableName }}
|
||||
/>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
<div className="flex items-center justify-center bg-white" style={{ minHeight: "600px" }}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면이 비어있습니다</h2>
|
||||
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면이 비어있습니다</h2>
|
||||
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 편집 모달 */}
|
||||
<EditModal
|
||||
|
|
|
|||
|
|
@ -132,3 +132,16 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dialog 오버레이 커스터마이징 - 어두운 배경 */
|
||||
[data-radix-dialog-overlay],
|
||||
.fixed.inset-0.z-50.bg-black {
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* DialogPrimitive.Overlay 클래스 오버라이드 */
|
||||
.fixed.inset-0.z-50 {
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,158 +20,129 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
|
||||
import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface DataFlowListProps {
|
||||
onDesignDiagram: (diagram: DataFlowDiagram | null) => void;
|
||||
// 노드 플로우 타입 정의
|
||||
interface NodeFlow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
||||
interface DataFlowListProps {
|
||||
onLoadFlow: (flowId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||
const { user } = useAuth();
|
||||
const [diagrams, setDiagrams] = useState<DataFlowDiagram[]>([]);
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 사용자 회사 코드 가져오기 (기본값: "*")
|
||||
const companyCode = user?.company_code || user?.companyCode || "*";
|
||||
|
||||
// 모달 상태
|
||||
const [showCopyModal, setShowCopyModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedDiagramForAction, setSelectedDiagramForAction] = useState<DataFlowDiagram | null>(null);
|
||||
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
||||
|
||||
// 목록 로드 함수 분리
|
||||
const loadDiagrams = useCallback(async () => {
|
||||
// 노드 플로우 목록 로드
|
||||
const loadFlows = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode);
|
||||
const response = await apiClient.get("/dataflow/node-flows");
|
||||
|
||||
// JSON API 응답을 기존 형식으로 변환
|
||||
const convertedDiagrams = response.diagrams.map((diagram) => {
|
||||
// relationships 구조 분석
|
||||
const relationships = diagram.relationships || {};
|
||||
|
||||
// 테이블 정보 추출
|
||||
const tables: string[] = [];
|
||||
if (relationships.fromTable?.tableName) {
|
||||
tables.push(relationships.fromTable.tableName);
|
||||
}
|
||||
if (
|
||||
relationships.toTable?.tableName &&
|
||||
relationships.toTable.tableName !== relationships.fromTable?.tableName
|
||||
) {
|
||||
tables.push(relationships.toTable.tableName);
|
||||
}
|
||||
|
||||
// 제어 수 계산 (actionGroups 기준)
|
||||
const actionGroups = relationships.actionGroups || [];
|
||||
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
|
||||
return count + (group.actions?.length || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
diagramId: diagram.diagram_id,
|
||||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||
diagramName: diagram.diagram_name,
|
||||
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
|
||||
relationshipType: "multi-relationship", // 다중 제어 타입
|
||||
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
|
||||
tableCount: tables.length,
|
||||
tables: tables,
|
||||
companyCode: diagram.company_code, // 회사 코드 추가
|
||||
createdAt: new Date(diagram.created_at || new Date()),
|
||||
createdBy: diagram.created_by || "SYSTEM",
|
||||
updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()),
|
||||
updatedBy: diagram.updated_by || "SYSTEM",
|
||||
lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
setDiagrams(convertedDiagrams);
|
||||
setTotal(response.pagination.total || 0);
|
||||
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
|
||||
if (response.data.success) {
|
||||
setFlows(response.data.data);
|
||||
} else {
|
||||
throw new Error(response.data.message || "플로우 목록 조회 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제어 목록 조회 실패", error);
|
||||
toast.error("제어 목록을 불러오는데 실패했습니다.");
|
||||
console.error("플로우 목록 조회 실패", error);
|
||||
toast.error("플로우 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, searchTerm, companyCode]);
|
||||
}, []);
|
||||
|
||||
// 제어 목록 로드
|
||||
// 플로우 목록 로드
|
||||
useEffect(() => {
|
||||
loadDiagrams();
|
||||
}, [loadDiagrams]);
|
||||
loadFlows();
|
||||
}, [loadFlows]);
|
||||
|
||||
const handleDelete = (diagram: DataFlowDiagram) => {
|
||||
setSelectedDiagramForAction(diagram);
|
||||
// 플로우 삭제
|
||||
const handleDelete = (flow: NodeFlow) => {
|
||||
setSelectedFlow(flow);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleCopy = (diagram: DataFlowDiagram) => {
|
||||
setSelectedDiagramForAction(diagram);
|
||||
setShowCopyModal(true);
|
||||
};
|
||||
|
||||
// 복사 확인
|
||||
const handleConfirmCopy = async () => {
|
||||
if (!selectedDiagramForAction) return;
|
||||
|
||||
// 플로우 복사
|
||||
const handleCopy = async (flow: NodeFlow) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram(
|
||||
selectedDiagramForAction.diagramId,
|
||||
companyCode,
|
||||
undefined,
|
||||
user?.userId || "SYSTEM",
|
||||
);
|
||||
toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
// 원본 플로우 데이터 가져오기
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "플로우 조회 실패");
|
||||
}
|
||||
|
||||
const originalFlow = response.data.data;
|
||||
|
||||
// 복사본 저장
|
||||
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
||||
flowName: `${flow.flowName} (복사본)`,
|
||||
flowDescription: flow.flowDescription,
|
||||
flowData: originalFlow.flowData,
|
||||
});
|
||||
|
||||
if (copyResponse.data.success) {
|
||||
toast.success(`플로우가 성공적으로 복사되었습니다`);
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제어 복사 실패:", error);
|
||||
toast.error("제어 복사에 실패했습니다.");
|
||||
console.error("플로우 복사 실패:", error);
|
||||
toast.error("플로우 복사에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowCopyModal(false);
|
||||
setSelectedDiagramForAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedDiagramForAction) return;
|
||||
if (!selectedFlow) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
|
||||
toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
|
||||
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
if (response.data.success) {
|
||||
toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`);
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(response.data.message || "플로우 삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제어 삭제 실패:", error);
|
||||
toast.error("제어 삭제에 실패했습니다.");
|
||||
console.error("플로우 삭제 실패:", error);
|
||||
toast.error("플로우 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteModal(false);
|
||||
setSelectedDiagramForAction(null);
|
||||
setSelectedFlow(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 검색 필터링
|
||||
const filteredFlows = flows.filter(
|
||||
(flow) =>
|
||||
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -181,173 +152,125 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
placeholder="제어명, 테이블명으로 검색..."
|
||||
placeholder="플로우명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-80 pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 제어 생성
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onLoadFlow(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 플로우 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 제어 목록 테이블 */}
|
||||
{/* 플로우 목록 테이블 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center">
|
||||
<Network className="mr-2 h-5 w-5" />
|
||||
데이터 흐름 제어 ({total})
|
||||
노드 플로우 목록 ({filteredFlows.length})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>제어명</TableHead>
|
||||
<TableHead>회사 코드</TableHead>
|
||||
<TableHead>테이블 수</TableHead>
|
||||
<TableHead>액션 수</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{diagrams.map((diagram) => (
|
||||
<TableRow key={diagram.diagramId} className="hover:bg-gray-50">
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="flex items-center font-medium text-gray-900">
|
||||
<Database className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{diagram.diagramName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
테이블: {diagram.tables.slice(0, 3).join(", ")}
|
||||
{diagram.tables.length > 3 && ` 외 ${diagram.tables.length - 3}개`}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{diagram.companyCode || "*"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Database className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{diagram.tableCount}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Network className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{diagram.relationshipCount}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(diagram.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-gray-400">
|
||||
<User className="mr-1 h-3 w-3" />
|
||||
{diagram.updatedBy}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{diagrams.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<div className="mb-2 text-lg font-medium">제어가 없습니다</div>
|
||||
<div className="text-sm">새 제어를 생성하여 테이블 간 데이터 제어를 설정해보세요.</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>플로우명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFlows.map((flow) => (
|
||||
<TableRow
|
||||
key={flow.flowId}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => onLoadFlow(flow.flowId)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center font-medium text-gray-900">
|
||||
<Network className="mr-2 h-4 w-4 text-blue-500" />
|
||||
{flow.flowName}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-500">{flow.flowDescription || "설명 없음"}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(flow.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(flow.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
불러오기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(flow)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{filteredFlows.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<div className="mb-2 text-lg font-medium">플로우가 없습니다</div>
|
||||
<div className="text-sm">새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 복사 확인 모달 */}
|
||||
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>제어 복사</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 복사하시겠습니까?
|
||||
<br />
|
||||
새로운 제어는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCopyModal(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirmCopy} disabled={loading}>
|
||||
{loading ? "복사 중..." : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-600">제어 삭제</DialogTitle>
|
||||
<DialogTitle className="text-red-600">플로우 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 완전히 삭제하시겠습니까?
|
||||
“{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="font-medium text-red-600">
|
||||
이 작업은 되돌릴 수 없으며, 모든 제어 정보가 영구적으로 삭제됩니다.
|
||||
이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@
|
|||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar";
|
||||
import { Boxes, Settings } from "lucide-react";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
|
|
@ -48,10 +51,38 @@ const nodeTypes = {
|
|||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
function FlowEditorInner() {
|
||||
interface FlowEditorInnerProps {
|
||||
initialFlowId?: number | null;
|
||||
}
|
||||
|
||||
// 플로우 에디터 툴바 버튼 설정
|
||||
const flowToolbarButtons: ToolbarButton[] = [
|
||||
{
|
||||
id: "nodes",
|
||||
label: "노드",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
shortcut: "N",
|
||||
group: "source",
|
||||
panelWidth: 300,
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: "속성",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 350,
|
||||
},
|
||||
];
|
||||
|
||||
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
// 패널 표시 상태
|
||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
||||
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
|
|
@ -61,13 +92,50 @@ function FlowEditorInner() {
|
|||
onNodeDragStart,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
setShowPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
undo,
|
||||
redo,
|
||||
loadFlow,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
// 속성 패널 상태 동기화
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
||||
setShowPropertiesPanelLocal(true);
|
||||
}
|
||||
}, [selectedNodes, showPropertiesPanelLocal]);
|
||||
|
||||
// 초기 플로우 로드
|
||||
useEffect(() => {
|
||||
const fetchAndLoadFlow = async () => {
|
||||
if (initialFlowId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const flow = response.data.data;
|
||||
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
loadFlow(
|
||||
flow.flowId,
|
||||
flow.flowName,
|
||||
flow.flowDescription || "",
|
||||
flowData.nodes || [],
|
||||
flowData.edges || [],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndLoadFlow();
|
||||
}, [initialFlowId]);
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
|
|
@ -178,10 +246,29 @@ function FlowEditorInner() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 좌측 노드 팔레트 */}
|
||||
<div className="w-[250px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
{/* 좌측 통합 툴바 */}
|
||||
<LeftUnifiedToolbar
|
||||
buttons={flowToolbarButtons}
|
||||
panelStates={{
|
||||
nodes: { isOpen: showNodesPanel },
|
||||
properties: { isOpen: showPropertiesPanelLocal },
|
||||
}}
|
||||
onTogglePanel={(panelId) => {
|
||||
if (panelId === "nodes") {
|
||||
setShowNodesPanel(!showNodesPanel);
|
||||
} else if (panelId === "properties") {
|
||||
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
||||
setShowPropertiesPanel(!showPropertiesPanelLocal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 노드 라이브러리 패널 */}
|
||||
{showNodesPanel && (
|
||||
<div className="h-full w-[300px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
|
|
@ -224,8 +311,8 @@ function FlowEditorInner() {
|
|||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanel && (
|
||||
<div className="w-[350px] border-l bg-white">
|
||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
||||
<div className="h-full w-[350px] border-l bg-white">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -236,11 +323,15 @@ function FlowEditorInner() {
|
|||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
export function FlowEditor() {
|
||||
interface FlowEditorProps {
|
||||
initialFlowId?: number | null;
|
||||
}
|
||||
|
||||
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] min-h-[700px] w-full">
|
||||
<div className="h-full w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner />
|
||||
<FlowEditorInner initialFlowId={initialFlowId} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,11 @@
|
|||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react";
|
||||
import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
import { LoadFlowDialog } from "./dialogs/LoadFlowDialog";
|
||||
import { getNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
export function FlowToolbar() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
|
|
@ -21,7 +18,6 @@ export function FlowToolbar() {
|
|||
validateFlow,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isExecuting,
|
||||
isSaving,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
|
|
@ -30,7 +26,6 @@ export function FlowToolbar() {
|
|||
canUndo,
|
||||
canRedo,
|
||||
} = useFlowEditorStore();
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
|
|
@ -62,29 +57,6 @@ export function FlowToolbar() {
|
|||
alert("✅ JSON 파일로 내보내기 완료!");
|
||||
};
|
||||
|
||||
const handleLoad = async (flowId: number) => {
|
||||
try {
|
||||
const flow = await getNodeFlow(flowId);
|
||||
|
||||
// flowData가 이미 객체인지 문자열인지 확인
|
||||
const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
// Zustand 스토어의 loadFlow 함수 호출
|
||||
useFlowEditorStore
|
||||
.getState()
|
||||
.loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges);
|
||||
alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`);
|
||||
} catch (error) {
|
||||
console.error("플로우 불러오기 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
// TODO: 실행 로직 구현
|
||||
alert("실행 기능 구현 예정");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("삭제할 노드를 선택해주세요.");
|
||||
|
|
@ -98,94 +70,74 @@ export function FlowToolbar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadFlowDialog open={showLoadDialog} onOpenChange={setShowLoadDialog} onLoad={handleLoad} />
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 불러오기 */}
|
||||
<Button variant="outline" size="sm" onClick={() => setShowLoadDialog(true)} className="gap-1">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-xs">불러오기</span>
|
||||
</Button>
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="gap-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-xs">{isExecuting ? "실행 중..." : "테스트 실행"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig";
|
|||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export function NodePalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["source", "transform", "action"]));
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(["source", "transform", "action", "utility"]),
|
||||
);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
|
|
@ -25,7 +27,7 @@ export function NodePalette() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b bg-gray-50 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">노드 라이브러리</h3>
|
||||
|
|
@ -46,7 +48,6 @@ export function NodePalette() {
|
|||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span>{category.icon}</span>
|
||||
<span>{category.label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{nodes.length}</span>
|
||||
</button>
|
||||
|
|
@ -89,13 +90,8 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
|||
title={node.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 아이콘 */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-lg"
|
||||
style={{ backgroundColor: `${node.color}20` }}
|
||||
>
|
||||
{node.icon}
|
||||
</div>
|
||||
{/* 색상 인디케이터 (좌측) */}
|
||||
<div className="h-8 w-1 flex-shrink-0 rounded" style={{ backgroundColor: node.color }} />
|
||||
|
||||
{/* 라벨 및 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -104,7 +100,7 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 인디케이터 */}
|
||||
{/* 하단 색상 인디케이터 (hover 시) */}
|
||||
<div
|
||||
className="mt-2 h-1 w-full rounded-full opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: node.color }}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "tableSource",
|
||||
label: "테이블",
|
||||
icon: "📊",
|
||||
icon: "",
|
||||
description: "내부 데이터베이스 테이블에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#3B82F6", // 파란색
|
||||
|
|
@ -19,7 +19,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "externalDBSource",
|
||||
label: "외부 DB",
|
||||
icon: "🔌",
|
||||
icon: "",
|
||||
description: "외부 데이터베이스에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#F59E0B", // 주황색
|
||||
|
|
@ -27,7 +27,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "restAPISource",
|
||||
label: "REST API",
|
||||
icon: "📁",
|
||||
icon: "",
|
||||
description: "REST API를 호출하여 데이터를 가져옵니다",
|
||||
category: "source",
|
||||
color: "#10B981", // 초록색
|
||||
|
|
@ -35,7 +35,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "referenceLookup",
|
||||
label: "참조 조회",
|
||||
icon: "🔗",
|
||||
icon: "",
|
||||
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
|
||||
category: "source",
|
||||
color: "#A855F7", // 보라색
|
||||
|
|
@ -47,7 +47,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "condition",
|
||||
label: "조건 분기",
|
||||
icon: "⚡",
|
||||
icon: "",
|
||||
description: "조건에 따라 데이터 흐름을 분기합니다",
|
||||
category: "transform",
|
||||
color: "#EAB308", // 노란색
|
||||
|
|
@ -55,7 +55,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "dataTransform",
|
||||
label: "데이터 변환",
|
||||
icon: "🔧",
|
||||
icon: "",
|
||||
description: "데이터를 변환하거나 가공합니다",
|
||||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
|
|
@ -67,7 +67,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "insertAction",
|
||||
label: "INSERT",
|
||||
icon: "➕",
|
||||
icon: "",
|
||||
description: "데이터를 삽입합니다",
|
||||
category: "action",
|
||||
color: "#22C55E", // 초록색
|
||||
|
|
@ -75,7 +75,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "updateAction",
|
||||
label: "UPDATE",
|
||||
icon: "✏️",
|
||||
icon: "",
|
||||
description: "데이터를 수정합니다",
|
||||
category: "action",
|
||||
color: "#3B82F6", // 파란색
|
||||
|
|
@ -83,7 +83,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "deleteAction",
|
||||
label: "DELETE",
|
||||
icon: "❌",
|
||||
icon: "",
|
||||
description: "데이터를 삭제합니다",
|
||||
category: "action",
|
||||
color: "#EF4444", // 빨간색
|
||||
|
|
@ -91,7 +91,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "upsertAction",
|
||||
label: "UPSERT",
|
||||
icon: "🔄",
|
||||
icon: "",
|
||||
description: "데이터를 삽입하거나 수정합니다",
|
||||
category: "action",
|
||||
color: "#8B5CF6", // 보라색
|
||||
|
|
@ -103,7 +103,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "comment",
|
||||
label: "주석",
|
||||
icon: "💬",
|
||||
icon: "",
|
||||
description: "주석을 추가합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
|
|
@ -111,7 +111,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "log",
|
||||
label: "로그",
|
||||
icon: "🔍",
|
||||
icon: "",
|
||||
description: "로그를 출력합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
|
|
@ -122,22 +122,22 @@ export const NODE_CATEGORIES = [
|
|||
{
|
||||
id: "source",
|
||||
label: "데이터 소스",
|
||||
icon: "📂",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
label: "변환/조건",
|
||||
icon: "🔀",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "액션",
|
||||
icon: "⚡",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
id: "utility",
|
||||
label: "유틸리티",
|
||||
icon: "🛠️",
|
||||
icon: "",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -114,11 +114,11 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight);
|
||||
|
||||
// console.log(`🔧 패널 높이 자동 조정:`, {
|
||||
// panelId: id,
|
||||
// contentHeight,
|
||||
// calculatedHeight: newHeight,
|
||||
// currentHeight: panelSize.height,
|
||||
// willUpdate: Math.abs(panelSize.height - newHeight) > 10,
|
||||
// panelId: id,
|
||||
// contentHeight,
|
||||
// calculatedHeight: newHeight,
|
||||
// currentHeight: panelSize.height,
|
||||
// willUpdate: Math.abs(panelSize.height - newHeight) > 10,
|
||||
// });
|
||||
|
||||
// 현재 높이와 다르면 업데이트
|
||||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[100] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
||||
"bg-card text-card-foreground fixed z-[100] rounded-lg border shadow-lg",
|
||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||
isResizing && "cursor-se-resize",
|
||||
className,
|
||||
|
|
@ -239,28 +239,28 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
height: `${panelSize.height}px`,
|
||||
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
||||
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
||||
zIndex: isDragging ? 101 : 100, // 항상 컴포넌트보다 위에 표시
|
||||
zIndex: isDragging ? 101 : 100,
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
ref={dragHandleRef}
|
||||
data-header="true"
|
||||
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"
|
||||
className="bg-muted/40 flex cursor-move items-center justify-between border-b px-4 py-3"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
userSelect: "none", // 텍스트 선택 방지
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</div>
|
||||
<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 onClick={onClose} className="hover:bg-accent rounded-md p-1.5 transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</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-gradient-to-br from-gray-400 to-gray-500 shadow-sm" />
|
||||
<div className="bg-muted-foreground/40 absolute right-1 bottom-1 h-2 w-2 rounded-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
showValidationPanel = false,
|
||||
validationOptions = {},
|
||||
}) => {
|
||||
// component가 없으면 빈 div 반환
|
||||
if (!component) {
|
||||
console.warn("⚠️ InteractiveScreenViewer: component가 undefined입니다.");
|
||||
return <div className="h-full w-full" />;
|
||||
}
|
||||
|
||||
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outline: "2px solid hsl(var(--primary))",
|
||||
outlineOffset: "2px",
|
||||
zIndex: 20, // 패널과 모달보다 낮게 설정
|
||||
zIndex: 20,
|
||||
}
|
||||
: {};
|
||||
|
||||
|
|
@ -183,16 +183,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-8 left-0 rounded-lg bg-gray-800/90 px-3 py-2 text-xs text-white shadow-lg backdrop-blur-sm">
|
||||
<div className="bg-primary text-primary-foreground absolute -top-7 left-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
|
||||
{type === "widget" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||
<span className="font-medium">{(component as WidgetComponent).widgetType || "widget"}</span>
|
||||
<span>{(component as WidgetComponent).widgetType || "widget"}</span>
|
||||
</div>
|
||||
)}
|
||||
{type !== "widget" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{component.componentConfig?.type || type}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{component.componentConfig?.type || type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* 반응형 레이아웃 엔진
|
||||
*
|
||||
* 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||
import { generateSmartDefaults, ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
export interface ResponsiveLayoutEngineProps {
|
||||
components: ComponentData[];
|
||||
breakpoint: Breakpoint;
|
||||
containerWidth: number;
|
||||
screenWidth?: number;
|
||||
preserveYPosition?: boolean; // true: Y좌표 유지 (하이브리드), false: 16px 간격 (반응형)
|
||||
formData?: Record<string, unknown>;
|
||||
onFormDataChange?: (fieldName: string, value: unknown) => void;
|
||||
screenInfo?: { id: number; tableName?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응형 레이아웃 엔진
|
||||
*
|
||||
* 변환 로직:
|
||||
* 1. Y 위치 기준으로 행(row)으로 그룹화
|
||||
* 2. 각 행 내에서 X 위치 기준으로 정렬
|
||||
* 3. 반응형 설정 적용 (order, gridColumns, hide)
|
||||
* 4. CSS Grid로 렌더링
|
||||
*/
|
||||
export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
|
||||
components,
|
||||
breakpoint,
|
||||
containerWidth,
|
||||
screenWidth = 1920,
|
||||
preserveYPosition = false, // 기본값: 반응형 모드 (16px 간격)
|
||||
formData,
|
||||
onFormDataChange,
|
||||
screenInfo,
|
||||
}) => {
|
||||
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
|
||||
const rows = useMemo(() => {
|
||||
const sortedComponents = [...components].sort((a, b) => a.position.y - b.position.y);
|
||||
|
||||
const rows: ComponentData[][] = [];
|
||||
let currentRow: ComponentData[] = [];
|
||||
let currentRowY = 0;
|
||||
const ROW_THRESHOLD = 150; // 같은 행으로 간주할 Y 오차 범위 (px) - 여유있게 설정
|
||||
|
||||
sortedComponents.forEach((comp) => {
|
||||
if (currentRow.length === 0) {
|
||||
currentRow.push(comp);
|
||||
currentRowY = comp.position.y;
|
||||
} else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) {
|
||||
currentRow.push(comp);
|
||||
} else {
|
||||
rows.push(currentRow);
|
||||
currentRow = [comp];
|
||||
currentRowY = comp.position.y;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [components]);
|
||||
|
||||
// 2단계: 각 행 내에서 X 위치 기준으로 정렬
|
||||
const sortedRows = useMemo(() => {
|
||||
return rows.map((row) => [...row].sort((a, b) => a.position.x - b.position.x));
|
||||
}, [rows]);
|
||||
|
||||
// 3단계: 반응형 설정 적용
|
||||
const responsiveComponents = useMemo(() => {
|
||||
const result = sortedRows.flatMap((row, rowIndex) =>
|
||||
row.map((comp, compIndex) => {
|
||||
// 컴포넌트에 gridColumns가 이미 설정되어 있으면 그 값 사용
|
||||
if ((comp as any).gridColumns !== undefined) {
|
||||
return {
|
||||
...comp,
|
||||
responsiveDisplay: {
|
||||
gridColumns: (comp as any).gridColumns,
|
||||
order: compIndex + 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 반응형 설정이 없으면 자동 생성
|
||||
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
|
||||
|
||||
// 현재 브레이크포인트의 설정 가져오기 (같은 행의 컴포넌트 개수 전달)
|
||||
const config = compWithConfig.responsiveConfig!.useSmartDefaults
|
||||
? generateSmartDefaults(comp, screenWidth, row.length)[breakpoint]
|
||||
: compWithConfig.responsiveConfig!.responsive?.[breakpoint];
|
||||
|
||||
const finalConfig = config || generateSmartDefaults(comp, screenWidth, row.length)[breakpoint];
|
||||
|
||||
return {
|
||||
...compWithConfig,
|
||||
responsiveDisplay: finalConfig,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}, [sortedRows, breakpoint, screenWidth]);
|
||||
|
||||
// 4단계: 필터링 및 정렬
|
||||
const visibleComponents = useMemo(() => {
|
||||
return responsiveComponents
|
||||
.filter((comp) => !comp.responsiveDisplay?.hide)
|
||||
.sort((a, b) => (a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0));
|
||||
}, [responsiveComponents]);
|
||||
|
||||
const gridColumns = BREAKPOINTS[breakpoint].columns;
|
||||
|
||||
// 각 행의 Y 위치를 추적
|
||||
const rowsWithYPosition = useMemo(() => {
|
||||
return sortedRows.map((row) => ({
|
||||
components: row,
|
||||
yPosition: Math.min(...row.map((c) => c.position.y)), // 행의 최소 Y 위치
|
||||
}));
|
||||
}, [sortedRows]);
|
||||
|
||||
return (
|
||||
<div className="responsive-container w-full" style={{ position: "relative" }}>
|
||||
{rowsWithYPosition.map((row, rowIndex) => {
|
||||
const rowComponents = visibleComponents.filter((vc) => row.components.some((rc) => rc.id === vc.id));
|
||||
|
||||
// Y 좌표 계산: preserveYPosition에 따라 다르게 처리
|
||||
let marginTop: string;
|
||||
if (preserveYPosition) {
|
||||
// 하이브리드 모드: 원래 Y 좌표 간격 유지
|
||||
if (rowIndex === 0) {
|
||||
marginTop = `${row.yPosition}px`;
|
||||
} else {
|
||||
const prevRowY = rowsWithYPosition[rowIndex - 1].yPosition;
|
||||
const actualGap = row.yPosition - prevRowY;
|
||||
marginTop = `${actualGap}px`;
|
||||
}
|
||||
} else {
|
||||
// 반응형 모드: 첫 번째는 맨 위부터 시작 (0px), 나머지는 16px 고정 간격
|
||||
marginTop = rowIndex === 0 ? "0px" : "16px";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className="responsive-grid w-full"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||
gap: "16px",
|
||||
padding: "0 16px",
|
||||
marginTop,
|
||||
alignItems: "start", // 각 아이템이 원래 높이 유지
|
||||
}}
|
||||
>
|
||||
{rowComponents.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="responsive-grid-item"
|
||||
style={{
|
||||
gridColumn: `span ${comp.responsiveDisplay?.gridColumns || gridColumns}`,
|
||||
height: "auto", // 자동 높이
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={comp}
|
||||
isPreview={true}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, createContext, useContext } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Monitor, Tablet, Smartphone, X } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine";
|
||||
import { Breakpoint } from "@/types/responsive";
|
||||
|
||||
// 미리보기 모달용 브레이크포인트 Context
|
||||
const PreviewBreakpointContext = createContext<Breakpoint | null>(null);
|
||||
|
||||
// 미리보기 모달 내에서 브레이크포인트를 가져오는 훅
|
||||
export const usePreviewBreakpoint = (): Breakpoint | null => {
|
||||
return useContext(PreviewBreakpointContext);
|
||||
};
|
||||
|
||||
interface ResponsivePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
components: ComponentData[];
|
||||
screenWidth: number;
|
||||
}
|
||||
|
||||
type DevicePreset = {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
icon: React.ReactNode;
|
||||
breakpoint: Breakpoint;
|
||||
};
|
||||
|
||||
const DEVICE_PRESETS: DevicePreset[] = [
|
||||
{
|
||||
name: "데스크톱",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
icon: <Monitor className="h-4 w-4" />,
|
||||
breakpoint: "desktop",
|
||||
},
|
||||
{
|
||||
name: "태블릿",
|
||||
width: 768,
|
||||
height: 1024,
|
||||
icon: <Tablet className="h-4 w-4" />,
|
||||
breakpoint: "tablet",
|
||||
},
|
||||
{
|
||||
name: "모바일",
|
||||
width: 375,
|
||||
height: 667,
|
||||
icon: <Smartphone className="h-4 w-4" />,
|
||||
breakpoint: "mobile",
|
||||
},
|
||||
];
|
||||
|
||||
export const ResponsivePreviewModal: React.FC<ResponsivePreviewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
components,
|
||||
screenWidth,
|
||||
}) => {
|
||||
const [selectedDevice, setSelectedDevice] = useState<DevicePreset>(DEVICE_PRESETS[0]);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
// 스케일 계산: 모달 내에서 디바이스가 잘 보이도록
|
||||
React.useEffect(() => {
|
||||
// 모달 내부 너비를 1400px로 가정하고 여백 100px 제외
|
||||
const maxWidth = 1300;
|
||||
const calculatedScale = Math.min(1, maxWidth / selectedDevice.width);
|
||||
setScale(calculatedScale);
|
||||
}, [selectedDevice]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[95vh] max-w-[95vw] p-0">
|
||||
<DialogHeader className="border-b px-6 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>반응형 미리보기</DialogTitle>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 디바이스 선택 버튼들 */}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{DEVICE_PRESETS.map((device) => (
|
||||
<Button
|
||||
key={device.name}
|
||||
variant={selectedDevice.name === device.name ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedDevice(device)}
|
||||
className="gap-2"
|
||||
>
|
||||
{device.icon}
|
||||
<span>{device.name}</span>
|
||||
<span className="text-xs opacity-70">
|
||||
{device.width}×{device.height}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 미리보기 영역 - Context Provider로 감싸서 브레이크포인트 전달 */}
|
||||
<PreviewBreakpointContext.Provider value={selectedDevice.breakpoint}>
|
||||
<div className="flex min-h-[600px] items-start justify-center overflow-auto bg-gray-50 p-6">
|
||||
<div
|
||||
className="relative border border-gray-300 bg-white shadow-2xl"
|
||||
style={{
|
||||
width: `${selectedDevice.width}px`,
|
||||
height: `${selectedDevice.height}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top center",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{/* 디바이스 프레임 헤더 (선택사항) */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<div className="text-xs text-gray-600">
|
||||
{selectedDevice.name} - {selectedDevice.width}×{selectedDevice.height}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">스케일: {Math.round(scale * 100)}%</div>
|
||||
</div>
|
||||
|
||||
{/* 실제 컴포넌트 렌더링 */}
|
||||
<div className="p-4">
|
||||
<ResponsiveLayoutEngine
|
||||
components={components}
|
||||
breakpoint={selectedDevice.breakpoint}
|
||||
containerWidth={selectedDevice.width}
|
||||
screenWidth={screenWidth}
|
||||
preserveYPosition={selectedDevice.breakpoint === "desktop"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewBreakpointContext.Provider>
|
||||
|
||||
{/* 푸터 정보 */}
|
||||
<div className="border-t bg-gray-50 px-6 py-3 text-xs text-gray-600">
|
||||
💡 Tip: 각 디바이스 버튼을 클릭하여 다양한 화면 크기에서 레이아웃을 확인할 수 있습니다.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "@/lib/utils/gridUtils";
|
||||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
|
|
@ -56,6 +57,7 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
|||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
import { ResponsivePreviewModal } from "./ResponsivePreviewModal";
|
||||
|
||||
// 새로운 통합 UI 컴포넌트
|
||||
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
|
||||
|
|
@ -143,6 +145,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
||||
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
|
||||
|
||||
// 반응형 미리보기 모달 상태
|
||||
const [showResponsivePreview, setShowResponsivePreview] = useState(false);
|
||||
|
||||
// 해상도 설정 상태
|
||||
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
|
||||
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
|
||||
|
|
@ -867,54 +872,58 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
||||
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
|
||||
const loadTable = async () => {
|
||||
try {
|
||||
// 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화)
|
||||
const [columnsResponse, tableLabelResponse] = await Promise.all([
|
||||
tableTypeApi.getColumns(selectedScreen.tableName),
|
||||
tableTypeApi.getTableLabel(selectedScreen.tableName),
|
||||
]);
|
||||
const loadScreenTable = async () => {
|
||||
const tableName = selectedScreen?.tableName;
|
||||
if (!tableName) {
|
||||
setTables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
|
||||
tableName: col.tableName || selectedScreen.tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
// 우선순위: displayName(라벨) > columnLabel > column_label > columnName > 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, // 🎯 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,
|
||||
}));
|
||||
try {
|
||||
// 테이블 라벨 조회
|
||||
const tableListResponse = await tableManagementApi.getTableList();
|
||||
const currentTable =
|
||||
tableListResponse.success && tableListResponse.data
|
||||
? tableListResponse.data.find((t) => t.tableName === tableName)
|
||||
: null;
|
||||
const tableLabel = currentTable?.displayName || tableName;
|
||||
|
||||
const tableInfo: TableInfo = {
|
||||
tableName: selectedScreen.tableName,
|
||||
// 테이블 라벨이 있으면 우선 표시, 없으면 테이블명 그대로
|
||||
tableLabel: tableLabelResponse.tableLabel || selectedScreen.tableName,
|
||||
columns: columns,
|
||||
};
|
||||
setTables([tableInfo]); // 단일 테이블 정보만 설정
|
||||
} catch (error) {
|
||||
// console.error("테이블 정보 로드 실패:", error);
|
||||
toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`);
|
||||
}
|
||||
};
|
||||
// 현재 화면의 테이블 컬럼 정보 조회
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
loadTable();
|
||||
} else {
|
||||
// 테이블명이 없는 경우 테이블 목록 초기화
|
||||
setTables([]);
|
||||
}
|
||||
}, [selectedScreen?.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 tableInfo: TableInfo = {
|
||||
tableName,
|
||||
tableLabel,
|
||||
columns,
|
||||
};
|
||||
|
||||
setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로)
|
||||
} catch (error) {
|
||||
console.error("화면 테이블 정보 로드 실패:", error);
|
||||
setTables([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreenTable();
|
||||
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -942,8 +951,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
migratedComponents: layoutToUse.components.length,
|
||||
sampleComponent: layoutToUse.components[0],
|
||||
});
|
||||
|
||||
toast.success("레이아웃이 새로운 그리드 시스템으로 자동 변환되었습니다.");
|
||||
}
|
||||
|
||||
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
||||
|
|
@ -1249,9 +1256,42 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.type === "component" && comp.componentType === "split-panel-layout") {
|
||||
const config = comp.componentConfig || {};
|
||||
const rightPanel = config.rightPanel || {};
|
||||
const leftPanel = config.leftPanel || {};
|
||||
const relationshipType = rightPanel.relation?.type || "detail";
|
||||
|
||||
// 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정
|
||||
if (relationshipType === "detail" && leftPanel.tableName) {
|
||||
console.log("🔧 분할 패널 자동 수정:", {
|
||||
componentId: comp.id,
|
||||
leftTableName: leftPanel.tableName,
|
||||
rightTableName: leftPanel.tableName,
|
||||
});
|
||||
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...config,
|
||||
rightPanel: {
|
||||
...rightPanel,
|
||||
tableName: leftPanel.tableName,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
// 해상도 정보를 포함한 레이아웃 데이터 생성
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
screenResolution: screenResolution,
|
||||
};
|
||||
console.log("💾 저장 시작:", {
|
||||
|
|
@ -1940,6 +1980,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
"checkbox-basic": 2, // 체크박스 (16.67%)
|
||||
"radio-basic": 3, // 라디오 (25%)
|
||||
"file-basic": 4, // 파일 (33%)
|
||||
"file-upload": 4, // 파일 업로드 (33%)
|
||||
"slider-basic": 3, // 슬라이더 (25%)
|
||||
"toggle-switch": 2, // 토글 스위치 (16.67%)
|
||||
"repeater-field-group": 6, // 반복 필드 그룹 (50%)
|
||||
|
||||
// 표시 컴포넌트 (DISPLAY 카테고리)
|
||||
"label-basic": 2, // 라벨 (16.67%)
|
||||
|
|
@ -1948,6 +1992,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
"badge-basic": 1, // 배지 (8.33%)
|
||||
"alert-basic": 6, // 알림 (50%)
|
||||
"divider-basic": 12, // 구분선 (100%)
|
||||
"divider-line": 12, // 구분선 (100%)
|
||||
"accordion-basic": 12, // 아코디언 (100%)
|
||||
"table-list": 12, // 테이블 리스트 (100%)
|
||||
"image-display": 4, // 이미지 표시 (33%)
|
||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
||||
|
||||
// 액션 컴포넌트 (ACTION 카테고리)
|
||||
"button-basic": 1, // 버튼 (8.33%)
|
||||
|
|
@ -2013,6 +2062,30 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
gridColumns,
|
||||
});
|
||||
|
||||
// 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가
|
||||
let enhancedDefaultConfig = { ...component.defaultConfig };
|
||||
if (
|
||||
component.id === "repeater-field-group" &&
|
||||
tables &&
|
||||
tables.length > 0 &&
|
||||
tables[0].columns &&
|
||||
tables[0].columns.length > 0
|
||||
) {
|
||||
const firstColumn = tables[0].columns[0];
|
||||
enhancedDefaultConfig = {
|
||||
...enhancedDefaultConfig,
|
||||
fields: [
|
||||
{
|
||||
name: firstColumn.columnName,
|
||||
label: firstColumn.columnLabel || firstColumn.columnName,
|
||||
type: (firstColumn.widgetType as any) || "text",
|
||||
required: firstColumn.required || false,
|
||||
placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
|
|
@ -2025,7 +2098,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
...component.defaultConfig,
|
||||
...enhancedDefaultConfig,
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
|
|
@ -3744,14 +3817,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
screenResolution={screenResolution}
|
||||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPreview={() => {
|
||||
toast.info("미리보기 기능은 준비 중입니다.");
|
||||
}}
|
||||
canUndo={historyIndex > 0}
|
||||
canRedo={historyIndex < history.length - 1}
|
||||
isSaving={isSaving}
|
||||
onPreview={() => setShowResponsivePreview(true)}
|
||||
/>
|
||||
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -3869,12 +3936,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
ref={canvasContainerRef}
|
||||
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
|
||||
>
|
||||
{/* Pan 모드 안내 */}
|
||||
{isPanMode && (
|
||||
<div className="pointer-events-none fixed top-20 left-1/2 z-50 -translate-x-1/2 transform rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-lg">
|
||||
🖐️ Pan 모드 활성화 - 드래그하여 캔버스 이동
|
||||
</div>
|
||||
)}
|
||||
{/* Pan 모드 안내 - 제거됨 */}
|
||||
|
||||
{/* 줌 레벨 표시 */}
|
||||
<div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200">
|
||||
|
|
@ -4204,6 +4266,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
screenId={selectedScreen.screenId}
|
||||
/>
|
||||
)}
|
||||
{/* 반응형 미리보기 모달 */}
|
||||
<ResponsivePreviewModal
|
||||
isOpen={showResponsivePreview}
|
||||
onClose={() => setShowResponsivePreview(false)}
|
||||
components={layout.components}
|
||||
screenWidth={screenResolution.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,26 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } 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";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import CreateScreenModal from "./CreateScreenModal";
|
||||
import CopyScreenModal from "./CopyScreenModal";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
|
||||
const InteractiveScreenViewer = dynamic(
|
||||
() => import("./InteractiveScreenViewer").then((mod) => mod.InteractiveScreenViewer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center p-8">로딩 중...</div>,
|
||||
},
|
||||
);
|
||||
|
||||
interface ScreenListProps {
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
|
|
@ -82,6 +97,22 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
|
||||
// 편집 관련 상태
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
||||
const [editFormData, setEditFormData] = useState({
|
||||
screenName: "",
|
||||
description: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
|
||||
// 미리보기 관련 상태
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [screenToPreview, setScreenToPreview] = useState<ScreenDefinition | null>(null);
|
||||
const [previewLayout, setPreviewLayout] = useState<any>(null);
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 화면 목록 로드 (실제 API)
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
|
|
@ -138,8 +169,42 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
};
|
||||
|
||||
const handleEdit = (screen: ScreenDefinition) => {
|
||||
// 편집 모달 열기
|
||||
// console.log("편집:", screen);
|
||||
setScreenToEdit(screen);
|
||||
setEditFormData({
|
||||
screenName: screen.screenName,
|
||||
description: screen.description || "",
|
||||
isActive: screen.isActive,
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!screenToEdit) return;
|
||||
|
||||
try {
|
||||
// 화면 정보 업데이트 API 호출
|
||||
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
|
||||
|
||||
// 목록에서 해당 화면 정보 업데이트
|
||||
setScreens((prev) =>
|
||||
prev.map((s) =>
|
||||
s.screenId === screenToEdit.screenId
|
||||
? {
|
||||
...s,
|
||||
screenName: editFormData.screenName,
|
||||
description: editFormData.description,
|
||||
isActive: editFormData.isActive,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
|
||||
setEditDialogOpen(false);
|
||||
setScreenToEdit(null);
|
||||
} catch (error) {
|
||||
console.error("화면 정보 업데이트 실패:", error);
|
||||
alert("화면 정보 업데이트에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (screen: ScreenDefinition) => {
|
||||
|
|
@ -295,9 +360,26 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
setIsCopyOpen(true);
|
||||
};
|
||||
|
||||
const handleView = (screen: ScreenDefinition) => {
|
||||
// 미리보기 모달 열기
|
||||
// console.log("미리보기:", screen);
|
||||
const handleView = async (screen: ScreenDefinition) => {
|
||||
setScreenToPreview(screen);
|
||||
setPreviewLayout(null); // 이전 레이아웃 초기화
|
||||
setIsLoadingPreview(true);
|
||||
setPreviewDialogOpen(true); // 모달 먼저 열기
|
||||
|
||||
// 모달이 열린 후에 레이아웃 로드
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 화면 레이아웃 로드
|
||||
const layoutData = await screenApi.getLayout(screen.screenId);
|
||||
console.log("📊 미리보기 레이아웃 로드:", layoutData);
|
||||
setPreviewLayout(layoutData);
|
||||
} catch (error) {
|
||||
console.error("❌ 레이아웃 로드 실패:", error);
|
||||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
}, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록
|
||||
};
|
||||
|
||||
const handleCopySuccess = () => {
|
||||
|
|
@ -329,11 +411,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
disabled={activeTab === "trash"}
|
||||
>
|
||||
<Button variant="default" onClick={() => setIsCreateOpen(true)} disabled={activeTab === "trash"}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 화면 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -386,7 +464,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm text-muted-foreground">{screen.tableLabel || screen.tableName}</span>
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{screen.tableLabel || screen.tableName}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
|
|
@ -399,7 +479,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-muted-foreground">{screen.createdDate.toLocaleDateString()}</div>
|
||||
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
|
||||
<div className="text-xs text-gray-400">{screen.createdBy}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -504,16 +584,18 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm text-muted-foreground">{screen.tableLabel || screen.tableName}</span>
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{screen.tableLabel || screen.tableName}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-muted-foreground">{screen.deletedDate?.toLocaleDateString()}</div>
|
||||
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-muted-foreground">{screen.deletedBy}</div>
|
||||
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-32 truncate text-sm text-muted-foreground" title={screen.deleteReason}>
|
||||
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
|
||||
{screen.deleteReason || "-"}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -563,7 +645,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
|
|
@ -643,7 +725,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{dep.screenName}</div>
|
||||
<div className="text-sm text-muted-foreground">화면 코드: {dep.screenCode}</div>
|
||||
<div className="text-muted-foreground text-sm">화면 코드: {dep.screenCode}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-orange-600">
|
||||
|
|
@ -737,16 +819,301 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
>
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmBulkDelete}
|
||||
variant="destructive"
|
||||
disabled={bulkDeleting}
|
||||
>
|
||||
<AlertDialogAction onClick={confirmBulkDelete} variant="destructive" disabled={bulkDeleting}>
|
||||
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개 영구 삭제`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 화면 편집 다이얼로그 */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>화면 정보 편집</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-screenName">화면명 *</Label>
|
||||
<Input
|
||||
id="edit-screenName"
|
||||
value={editFormData.screenName}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, screenName: e.target.value })}
|
||||
placeholder="화면명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">설명</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={editFormData.description}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
|
||||
placeholder="화면 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-isActive">상태</Label>
|
||||
<Select
|
||||
value={editFormData.isActive}
|
||||
onValueChange={(value) => setEditFormData({ ...editFormData, isActive: value })}
|
||||
>
|
||||
<SelectTrigger id="edit-isActive">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">활성</SelectItem>
|
||||
<SelectItem value="N">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 화면 미리보기 다이얼로그 */}
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent className="h-[95vh] max-w-[95vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>화면 미리보기 - {screenToPreview?.screenName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
|
||||
{isLoadingPreview ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-medium">레이아웃 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500">화면 정보를 불러오고 있습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : previewLayout && previewLayout.components ? (
|
||||
(() => {
|
||||
const screenWidth = previewLayout.screenResolution?.width || 1200;
|
||||
const screenHeight = previewLayout.screenResolution?.height || 800;
|
||||
|
||||
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
|
||||
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - 100 : 1800; // 95vw - 패딩
|
||||
|
||||
// 가로폭 기준으로 스케일 계산 (가로폭에 맞춤)
|
||||
const scale = availableWidth / screenWidth;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mx-auto rounded-xl border border-gray-200/60 bg-white shadow-lg shadow-gray-900/5"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{/* 실제 화면과 동일한 렌더링 */}
|
||||
{previewLayout.components
|
||||
.filter((comp: any) => !comp.parentId) // 최상위 컴포넌트만 렌더링
|
||||
.map((component: any) => {
|
||||
if (!component || !component.id) return null;
|
||||
|
||||
// 그룹 컴포넌트인 경우 특별 처리
|
||||
if (component.type === "group") {
|
||||
const groupChildren = previewLayout.components.filter(
|
||||
(child: any) => child.parentId === component.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position?.x || 0}px`,
|
||||
top: `${component.position?.y || 0}px`,
|
||||
width: component.style?.width || `${component.size?.width || 200}px`,
|
||||
height: component.style?.height || `${component.size?.height || 40}px`,
|
||||
zIndex: component.position?.z || 1,
|
||||
backgroundColor: component.backgroundColor || "rgba(59, 130, 246, 0.05)",
|
||||
border: component.border || "1px solid rgba(59, 130, 246, 0.2)",
|
||||
borderRadius: component.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.title && (
|
||||
<div className="mb-3 inline-block rounded-lg bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700">
|
||||
{component.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
||||
{groupChildren.map((child: any) => (
|
||||
<div
|
||||
key={child.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${child.position.x}px`,
|
||||
top: `${child.position.y}px`,
|
||||
width: child.style?.width || `${child.size.width}px`,
|
||||
height: child.style?.height || `${child.size.height}px`,
|
||||
zIndex: child.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={child}
|
||||
allComponents={previewLayout.components}
|
||||
formData={previewFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: screenToPreview!.screenId,
|
||||
tableName: screenToPreview?.tableName,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 라벨 표시 여부 계산
|
||||
const templateTypes = ["datatable"];
|
||||
const shouldShowLabel =
|
||||
component.style?.labelDisplay !== false &&
|
||||
(component.label || component.style?.labelText) &&
|
||||
!templateTypes.includes(component.type);
|
||||
|
||||
const labelText = component.style?.labelText || component.label || "";
|
||||
const labelStyle = {
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
};
|
||||
const labelMarginBottom = component.style?.labelMarginBottom || "4px";
|
||||
|
||||
// 일반 컴포넌트 렌더링
|
||||
return (
|
||||
<div key={component.id}>
|
||||
{/* 라벨을 외부에 별도로 렌더링 */}
|
||||
{shouldShowLabel && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
|
||||
zIndex: (component.position.z || 1) + 1,
|
||||
...labelStyle,
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 컴포넌트 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
>
|
||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
|
||||
},
|
||||
}}
|
||||
isInteractive={true}
|
||||
formData={previewFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenId={screenToPreview!.screenId}
|
||||
tableName={screenToPreview?.tableName}
|
||||
/>
|
||||
) : (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
return "file";
|
||||
}
|
||||
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
|
||||
return getComponentWebType(component) || "text";
|
||||
})()}
|
||||
config={component.webTypeConfig}
|
||||
props={{
|
||||
component: component,
|
||||
value: previewFormData[component.columnName || component.id] || "",
|
||||
onChange: (value: any) => {
|
||||
const fieldName = component.columnName || component.id;
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
},
|
||||
onFormDataChange: (fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
},
|
||||
isInteractive: true,
|
||||
formData: previewFormData,
|
||||
readonly: component.readonly,
|
||||
required: component.required,
|
||||
placeholder: component.placeholder,
|
||||
className: "w-full h-full",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-medium text-gray-600">레이아웃이 비어있습니다</div>
|
||||
<div className="text-sm text-gray-500">이 화면에는 아직 컴포넌트가 배치되지 않았습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button onClick={() => onDesignScreen(screenToPreview!)}>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
편집 모드로 전환
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,79 +28,91 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 p-4 ${className}`}>
|
||||
<div className={`space-y-6 p-6 ${className}`}>
|
||||
{/* 여백 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box className="h-4 w-4 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">여백</h3>
|
||||
<Box className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">여백</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin">외부 여백</Label>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="margin" className="text-xs font-medium">
|
||||
외부 여백
|
||||
</Label>
|
||||
<Input
|
||||
id="margin"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
placeholder="10px"
|
||||
value={localStyle.margin || ""}
|
||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding">내부 여백</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="padding" className="text-xs font-medium">
|
||||
내부 여백
|
||||
</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
placeholder="10px"
|
||||
value={localStyle.padding || ""}
|
||||
onChange={(e) => handleStyleChange("padding", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap">간격</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px, 1rem"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="gap" className="text-xs font-medium">
|
||||
간격
|
||||
</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테두리 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="h-4 w-4 text-green-600" />
|
||||
<h3 className="font-semibold text-gray-900">테두리</h3>
|
||||
<Square className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">테두리</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderWidth">테두리 두께</Label>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="borderWidth" className="text-xs font-medium">
|
||||
두께
|
||||
</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px, 2px"
|
||||
placeholder="1px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderStyle">테두리 스타일</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="borderStyle" className="text-xs font-medium">
|
||||
스타일
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -113,24 +125,39 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderColor">테두리 색상</Label>
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="borderColor" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
className="h-8 w-14 p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="borderRadius">모서리 둥글기</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="borderRadius" className="text-xs font-medium">
|
||||
모서리
|
||||
</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px, 10px"
|
||||
placeholder="5px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,74 +165,106 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
</div>
|
||||
|
||||
{/* 배경 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="font-semibold text-gray-900">배경</h3>
|
||||
<Palette className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">배경</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundColor">배경 색상</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="backgroundColor" className="text-xs font-medium">
|
||||
배경 색상
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 w-14 p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backgroundImage">배경 이미지</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
||||
배경 이미지
|
||||
</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 섹션 */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4 text-orange-600" />
|
||||
<h3 className="font-semibold text-gray-900">텍스트</h3>
|
||||
<Type className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">텍스트</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">텍스트 색상</Label>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="color" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
className="h-8 w-14 p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontSize">글자 크기</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fontSize" className="text-xs font-medium">
|
||||
크기
|
||||
</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="text"
|
||||
placeholder="14px, 1rem"
|
||||
placeholder="14px"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fontWeight">글자 굵기</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fontWeight" className="text-xs font-medium">
|
||||
굵기
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -219,13 +278,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textAlign">텍스트 정렬</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="textAlign" className="text-xs font-medium">
|
||||
정렬
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer } from "lucide-react";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3 } from "lucide-react";
|
||||
|
||||
interface ComponentsPanelProps {
|
||||
className?: string;
|
||||
|
|
@ -14,21 +14,20 @@ interface ComponentsPanelProps {
|
|||
|
||||
export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<"all" | "display" | "action" | "layout" | "utility">("all");
|
||||
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
const components = ComponentRegistry.getAllComponents();
|
||||
|
||||
|
||||
// 수동으로 table-list 컴포넌트 추가 (임시)
|
||||
const hasTableList = components.some(c => c.id === 'table-list');
|
||||
const hasTableList = components.some((c) => c.id === "table-list");
|
||||
if (!hasTableList) {
|
||||
components.push({
|
||||
id: 'table-list',
|
||||
name: '데이터 테이블 v2',
|
||||
description: '검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트',
|
||||
category: 'display',
|
||||
tags: ['table', 'data', 'crud'],
|
||||
id: "table-list",
|
||||
name: "데이터 테이블 v2",
|
||||
description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
||||
category: "display",
|
||||
tags: ["table", "data", "crud"],
|
||||
defaultSize: { width: 1000, height: 680 },
|
||||
} as ComponentDefinition);
|
||||
}
|
||||
|
|
@ -38,18 +37,22 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
|
||||
// 카테고리별 컴포넌트 그룹화
|
||||
const componentsByCategory = useMemo(() => {
|
||||
// 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들)
|
||||
const hiddenInputComponents = ["text-input", "number-input", "date-input", "textarea-basic"];
|
||||
|
||||
return {
|
||||
all: allComponents,
|
||||
display: allComponents.filter((c) => c.category === "display"),
|
||||
action: allComponents.filter((c) => c.category === "action"),
|
||||
layout: allComponents.filter((c) => c.category === "layout"),
|
||||
utility: allComponents.filter((c) => c.category === "utility"),
|
||||
input: allComponents.filter(
|
||||
(c) => c.category === ComponentCategory.INPUT && !hiddenInputComponents.includes(c.id),
|
||||
),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||
};
|
||||
}, [allComponents]);
|
||||
|
||||
// 검색 및 필터링된 컴포넌트
|
||||
const filteredComponents = useMemo(() => {
|
||||
let components = selectedCategory === "all" ? componentsByCategory.all : componentsByCategory[selectedCategory as keyof typeof componentsByCategory];
|
||||
// 카테고리별 검색 필터링
|
||||
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
|
||||
let components = componentsByCategory[category];
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
|
@ -57,12 +60,12 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
(component: ComponentDefinition) =>
|
||||
component.name.toLowerCase().includes(query) ||
|
||||
component.description.toLowerCase().includes(query) ||
|
||||
component.tags?.some((tag: string) => tag.toLowerCase().includes(query))
|
||||
component.tags?.some((tag: string) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return components;
|
||||
}, [componentsByCategory, selectedCategory, searchQuery]);
|
||||
};
|
||||
|
||||
// 카테고리 아이콘 매핑
|
||||
const getCategoryIcon = (category: ComponentCategory) => {
|
||||
|
|
@ -90,144 +93,130 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex h-full flex-col bg-slate-50 p-6 border-r border-gray-200/60 shadow-sm ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">컴포넌트</h2>
|
||||
<p className="text-sm text-gray-500">7개의 사용 가능한 컴포넌트</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<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={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
||||
/>
|
||||
// 컴포넌트 카드 렌더링 함수
|
||||
const renderComponentCard = (component: ComponentDefinition) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(e, component);
|
||||
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 bg-card hover:border-primary/50 cursor-grab rounded-lg border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:scale-[0.98] active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 text-primary group-hover:bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md transition-all duration-200">
|
||||
{getCategoryIcon(component.category)}
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span>전체</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "display" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("display")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
<span>표시</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "action" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("action")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>액션</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "layout" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("layout")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
<span>레이아웃</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "utility" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("utility")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span>유틸리티</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
|
||||
{filteredComponents.length > 0 ? (
|
||||
filteredComponents.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(e, component);
|
||||
// 드래그 시작 시 시각적 피드백
|
||||
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-6 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"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-purple-100 text-purple-700 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="secondary" className="text-xs bg-purple-50 text-purple-600 border-0 ml-2 px-2 py-1 rounded-full font-medium">
|
||||
신규
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{component.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-purple-100 px-3 py-1 rounded-full font-medium text-purple-700 shadow-sm">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-primary capitalize bg-gradient-to-r from-purple-50 to-indigo-50 px-3 py-1 rounded-full border border-primary/20/50">
|
||||
{component.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center 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-muted-foreground">컴포넌트를 찾을 수 없습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="rounded-xl bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-100/60 p-4 mt-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<MousePointer className="h-4 w-4 text-purple-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
컴포넌트를 <span className="font-semibold text-purple-700">드래그</span>하여 화면에 추가하세요
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="mb-1 text-xs leading-tight font-semibold">{component.name}</h4>
|
||||
<p className="text-muted-foreground mb-1.5 line-clamp-2 text-xs leading-relaxed">{component.description}</p>
|
||||
<div className="flex items-center">
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 빈 상태 렌더링
|
||||
const renderEmptyState = () => (
|
||||
<div className="flex h-32 items-center justify-center text-center">
|
||||
<div className="p-6">
|
||||
<Package className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-xs font-medium">컴포넌트를 찾을 수 없습니다</p>
|
||||
<p className="text-muted-foreground/60 mt-1 text-xs">검색어를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`bg-background flex h-full flex-col p-4 ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3">
|
||||
<h2 className="mb-0.5 text-sm font-semibold">컴포넌트</h2>
|
||||
<p className="text-muted-foreground text-xs">{allComponents.length}개 사용 가능</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="mb-3">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="컴포넌트 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full grid-cols-4">
|
||||
<TabsTrigger value="input" className="flex items-center gap-1 px-1 text-xs">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">입력</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center gap-1 px-1 text-xs">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">액션</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center gap-1 px-1 text-xs">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">표시</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="layout" className="flex items-center gap-1 px-1 text-xs">
|
||||
<Layers className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">레이아웃</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 입력 컴포넌트 */}
|
||||
<TabsContent value="input" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("input").length > 0
|
||||
? getFilteredComponents("input").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 액션 컴포넌트 */}
|
||||
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("action").length > 0
|
||||
? getFilteredComponents("action").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 표시 컴포넌트 */}
|
||||
<TabsContent value="display" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("display").length > 0
|
||||
? getFilteredComponents("display").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 레이아웃 컴포넌트 */}
|
||||
<TabsContent value="layout" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("layout").length > 0
|
||||
? getFilteredComponents("layout").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="border-primary/20 bg-primary/5 mt-3 rounded-lg border p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MousePointer className="text-primary mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
컴포넌트를 <span className="text-foreground font-semibold">드래그</span>하여 화면에 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ interface DetailSettingsPanelProps {
|
|||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||
currentTableName?: string; // 현재 화면의 테이블명
|
||||
tables?: TableInfo[]; // 전체 테이블 목록
|
||||
}
|
||||
|
||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
|
|
@ -44,6 +45,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty,
|
||||
currentTable,
|
||||
currentTableName,
|
||||
tables = [], // 기본값 빈 배열
|
||||
}) => {
|
||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
|
@ -79,30 +81,30 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">레이아웃 설정</h3>
|
||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">타입:</span>
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">타입:</span>
|
||||
<span className="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
{layoutComponent.layoutType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">ID: {layoutComponent.id}</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">ID: {layoutComponent.id}</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 영역 */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">레이아웃 이름</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">레이아웃 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={layoutComponent.label || ""}
|
||||
onChange={(e) => onUpdateProperty(layoutComponent.id, "label", e.target.value)}
|
||||
className="focus:border-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500"
|
||||
className="border-input bg-background focus-visible:ring-ring h-8 w-full rounded-md border px-3 text-xs focus-visible:ring-1 focus-visible:outline-none"
|
||||
placeholder="레이아웃 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1104,6 +1106,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// });
|
||||
return currentTable?.columns || [];
|
||||
})()}
|
||||
tables={tables} // 전체 테이블 목록 전달
|
||||
onChange={(newConfig) => {
|
||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
|
|
|
|||
|
|
@ -55,44 +55,44 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="border-b p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-medium text-gray-900">격자 설정</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3X3 className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">격자 설정</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onForceGridUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onForceGridUpdate}
|
||||
className="flex items-center space-x-1"
|
||||
className="h-7 px-2 text-xs"
|
||||
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
<span>재정렬</span>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
재정렬
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="flex items-center space-x-1">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span>초기화</span>
|
||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="h-7 px-2 text-xs">
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 주요 토글들 */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{gridSettings.showGrid ? (
|
||||
<Eye className="h-4 w-4 text-primary" />
|
||||
<Eye className="text-primary h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Label htmlFor="showGrid" className="text-sm font-medium">
|
||||
<Label htmlFor="showGrid" className="text-xs font-medium">
|
||||
격자 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -104,9 +104,9 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4 text-green-600" />
|
||||
<Label htmlFor="snapToGrid" className="text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="text-primary h-3.5 w-3.5" />
|
||||
<Label htmlFor="snapToGrid" className="text-xs font-medium">
|
||||
격자 스냅
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -120,14 +120,14 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* 격자 구조 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">격자 구조</h4>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold">격자 구조</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="columns" className="mb-2 block text-sm font-medium">
|
||||
컬럼 수: {gridSettings.columns}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columns" className="text-xs font-medium">
|
||||
컬럼 수: <span className="text-primary">{gridSettings.columns}</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="columns"
|
||||
|
|
@ -138,15 +138,15 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
onValueChange={([value]) => updateSetting("columns", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>1</span>
|
||||
<span>24</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="gap" className="mb-2 block text-sm font-medium">
|
||||
간격: {gridSettings.gap}px
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gap" className="text-xs font-medium">
|
||||
간격: <span className="text-primary">{gridSettings.gap}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="gap"
|
||||
|
|
@ -157,15 +157,15 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
onValueChange={([value]) => updateSetting("gap", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>0px</span>
|
||||
<span>40px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="padding" className="mb-2 block text-sm font-medium">
|
||||
여백: {gridSettings.padding}px
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="padding" className="text-xs font-medium">
|
||||
여백: <span className="text-primary">{gridSettings.padding}px</span>
|
||||
</Label>
|
||||
<Slider
|
||||
id="padding"
|
||||
|
|
@ -176,7 +176,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
onValueChange={([value]) => updateSetting("padding", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-500">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>0px</span>
|
||||
<span>60px</span>
|
||||
</div>
|
||||
|
|
@ -248,8 +248,8 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
opacity: gridSettings.gridOpacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300 bg-primary/20">
|
||||
<span className="text-xs text-primary">컴포넌트 예시</span>
|
||||
<div className="bg-primary/20 flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300">
|
||||
<span className="text-primary text-xs">컴포넌트 예시</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -257,7 +257,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-xs text-muted-foreground">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
||||
<div className="text-muted-foreground text-xs">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
||||
|
||||
{/* 해상도 및 격자 정보 */}
|
||||
{screenResolution && actualGridInfo && (
|
||||
|
|
|
|||
|
|
@ -481,9 +481,13 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">컴포넌트를 선택하세요</h3>
|
||||
<p className="text-sm text-gray-500">캔버스에서 컴포넌트를 클릭하면 속성을 편집할 수 있습니다.</p>
|
||||
<Settings className="text-muted-foreground mb-3 h-10 w-10" />
|
||||
<h3 className="mb-2 text-sm font-semibold">컴포넌트를 선택하세요</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
캔버스에서 컴포넌트를 클릭하면
|
||||
<br />
|
||||
속성을 편집할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -535,58 +539,58 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">속성 편집</h3>
|
||||
<h3 className="text-sm font-semibold">속성 편집</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="secondary" className="text-xs font-medium">
|
||||
{selectedComponent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onCopyComponent} className="flex items-center space-x-1">
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>복사</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={onCopyComponent} className="h-8 px-2.5 text-xs">
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
|
||||
{canGroup && (
|
||||
<Button size="sm" variant="outline" onClick={onGroupComponents} className="flex items-center space-x-1">
|
||||
<Group className="h-3 w-3" />
|
||||
<span>그룹</span>
|
||||
<Button size="sm" variant="outline" onClick={onGroupComponents} className="h-8 px-2.5 text-xs">
|
||||
<Group className="mr-1 h-3 w-3" />
|
||||
그룹
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canUngroup && (
|
||||
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="flex items-center space-x-1">
|
||||
<Ungroup className="h-3 w-3" />
|
||||
<span>해제</span>
|
||||
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="h-8 px-2.5 text-xs">
|
||||
<Ungroup className="mr-1 h-3 w-3" />
|
||||
해제
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="flex items-center space-x-1">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>삭제</span>
|
||||
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="h-8 px-2.5 text-xs">
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 속성 편집 영역 */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="text-muted-foreground h-4 w-4" />
|
||||
<h4 className="font-medium text-gray-900">기본 정보</h4>
|
||||
<h4 className="text-sm font-semibold">기본 정보</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedComponent.type === "widget" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="columnName" className="text-sm font-medium">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="columnName" className="text-xs font-medium">
|
||||
컬럼명 (읽기 전용)
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -594,21 +598,20 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
value={selectedComponent.columnName || ""}
|
||||
readOnly
|
||||
placeholder="데이터베이스 컬럼명"
|
||||
className="text-muted-foreground mt-1 bg-gray-50"
|
||||
className="bg-muted/50 text-muted-foreground h-8"
|
||||
title="컬럼명은 변경할 수 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="inputType" className="text-sm font-medium">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="inputType" className="text-xs font-medium">
|
||||
입력 타입
|
||||
</Label>
|
||||
<select
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="border-input bg-background focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
|
||||
value={getBaseInputType(localInputs.widgetType)}
|
||||
onChange={(e) => {
|
||||
const selectedInputType = e.target.value as BaseInputType;
|
||||
// 입력 타입에 맞는 기본 세부 타입 설정
|
||||
const defaultWebType = getDefaultDetailType(selectedInputType);
|
||||
setLocalInputs((prev) => ({ ...prev, widgetType: defaultWebType }));
|
||||
onUpdateProperty("widgetType", defaultWebType);
|
||||
|
|
@ -620,11 +623,11 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">세부 타입은 "상세 설정" 패널에서 선택하세요</p>
|
||||
<p className="text-muted-foreground text-xs">세부 타입은 "상세 설정" 패널에서 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="placeholder" className="text-sm font-medium">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="placeholder" className="text-xs font-medium">
|
||||
플레이스홀더
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -632,12 +635,11 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
value={localInputs.placeholder}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// console.log("🔄 placeholder 변경:", newValue);
|
||||
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
|
||||
onUpdateProperty("placeholder", newValue);
|
||||
}}
|
||||
placeholder="입력 힌트 텍스트"
|
||||
className="mt-1"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* 반응형 설정 패널
|
||||
*
|
||||
* 컴포넌트별로 브레이크포인트마다 다른 레이아웃 설정 가능
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { Breakpoint, BREAKPOINTS, ResponsiveComponentConfig } from "@/types/responsive";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface ResponsiveConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdate: (config: ResponsiveComponentConfig) => void;
|
||||
}
|
||||
|
||||
export const ResponsiveConfigPanel: React.FC<ResponsiveConfigPanelProps> = ({ component, onUpdate }) => {
|
||||
const [activeTab, setActiveTab] = useState<Breakpoint>("desktop");
|
||||
|
||||
const config = component.responsiveConfig || {
|
||||
designerPosition: {
|
||||
x: component.position.x,
|
||||
y: component.position.y,
|
||||
width: component.size.width,
|
||||
height: component.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>반응형 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 스마트 기본값 토글 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="smartDefaults"
|
||||
checked={config.useSmartDefaults}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
useSmartDefaults: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="smartDefaults">스마트 기본값 사용 (권장)</Label>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
스마트 기본값은 컴포넌트 크기에 따라 자동으로 반응형 레이아웃을 생성합니다.
|
||||
</div>
|
||||
|
||||
{/* 수동 설정 */}
|
||||
{!config.useSmartDefaults && (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as Breakpoint)}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="desktop">데스크톱</TabsTrigger>
|
||||
<TabsTrigger value="tablet">태블릿</TabsTrigger>
|
||||
<TabsTrigger value="mobile">모바일</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="space-y-4">
|
||||
{/* 그리드 컬럼 수 */}
|
||||
<div className="space-y-2">
|
||||
<Label>너비 (그리드 컬럼)</Label>
|
||||
<Select
|
||||
value={config.responsive?.[activeTab]?.gridColumns?.toString()}
|
||||
onValueChange={(v) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
gridColumns: parseInt(v),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="컬럼 수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...Array(BREAKPOINTS[activeTab].columns)].map((_, i) => {
|
||||
const cols = i + 1;
|
||||
const percent = ((cols / BREAKPOINTS[activeTab].columns) * 100).toFixed(0);
|
||||
return (
|
||||
<SelectItem key={cols} value={cols.toString()}>
|
||||
{cols} / {BREAKPOINTS[activeTab].columns} ({percent}%)
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label>표시 순서</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.responsive?.[activeTab]?.order || 1}
|
||||
onChange={(e) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
order: parseInt(e.target.value),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-gray-500">작은 숫자가 먼저 표시됩니다.</div>
|
||||
</div>
|
||||
|
||||
{/* 숨김 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`hide-${activeTab}`}
|
||||
checked={config.responsive?.[activeTab]?.hide || false}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
responsive: {
|
||||
...config.responsive,
|
||||
[activeTab]: {
|
||||
...config.responsive?.[activeTab],
|
||||
hide: checked as boolean,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`hide-${activeTab}`}>
|
||||
{activeTab === "desktop" ? "데스크톱" : activeTab === "tablet" ? "태블릿" : "모바일"}에서 숨김
|
||||
</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -34,7 +34,7 @@ const getWidgetIcon = (widgetType: WebType) => {
|
|||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return <Type className="h-3 w-3 text-primary" />;
|
||||
return <Type className="text-primary h-3 w-3" />;
|
||||
case "number":
|
||||
case "decimal":
|
||||
return <Hash className="h-3 w-3 text-green-600" />;
|
||||
|
|
@ -49,9 +49,9 @@ const getWidgetIcon = (widgetType: WebType) => {
|
|||
return <AlignLeft className="h-3 w-3 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return <CheckSquare className="h-3 w-3 text-primary" />;
|
||||
return <CheckSquare className="text-primary h-3 w-3" />;
|
||||
case "code":
|
||||
return <Code className="h-3 w-3 text-muted-foreground" />;
|
||||
return <Code className="text-muted-foreground h-3 w-3" />;
|
||||
case "entity":
|
||||
return <Building className="h-3 w-3 text-cyan-600" />;
|
||||
case "file":
|
||||
|
|
@ -89,55 +89,55 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="border-b p-4">
|
||||
{selectedTableName && (
|
||||
<div className="mb-3 rounded-md bg-accent p-3">
|
||||
<div className="text-sm font-medium text-blue-900">선택된 테이블</div>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Database className="h-3 w-3 text-primary" />
|
||||
<span className="font-mono text-xs text-blue-800">{selectedTableName}</span>
|
||||
<div className="border-primary/20 bg-primary/5 mb-3 rounded-lg border p-3">
|
||||
<div className="text-xs font-semibold">선택된 테이블</div>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Database className="text-primary h-3 w-3" />
|
||||
<span className="font-mono text-xs font-medium">{selectedTableName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="테이블명, 컬럼명으로 검색..."
|
||||
placeholder="테이블명, 컬럼명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||
className="border-input bg-background focus-visible:ring-ring h-8 w-full rounded-md border px-3 pl-8 text-xs focus-visible:ring-1 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">총 {filteredTables.length}개 테이블</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">총 {filteredTables.length}개</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 목록 */}
|
||||
<div className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-1.5 p-3">
|
||||
{filteredTables.map((table) => {
|
||||
const isExpanded = expandedTables.has(table.tableName);
|
||||
|
||||
return (
|
||||
<div key={table.tableName} className="rounded-md border border-gray-200">
|
||||
<div key={table.tableName} className="bg-card rounded-lg border">
|
||||
{/* 테이블 헤더 */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50"
|
||||
className="hover:bg-accent/50 flex cursor-pointer items-center justify-between p-2.5 transition-colors"
|
||||
onClick={() => toggleTable(table.tableName)}
|
||||
>
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
<ChevronDown className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||
<ChevronRight className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{table.tableLabel || table.tableName}</div>
|
||||
<div className="text-xs text-gray-500">{table.columns.length}개 컬럼</div>
|
||||
<Database className="text-primary h-3.5 w-3.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">{table.tableLabel || table.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">{table.columns.length}개</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
variant="ghost"
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table)}
|
||||
className="ml-2 text-xs"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
드래그
|
||||
</Button>
|
||||
|
|
@ -154,43 +154,33 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
|
||||
{/* 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-gray-50">
|
||||
<div
|
||||
className={`${
|
||||
table.columns.length > 8
|
||||
? "scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 max-h-64 overflow-y-auto"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "#cbd5e1 #f1f5f9",
|
||||
}}
|
||||
>
|
||||
<div className="bg-muted/30 border-t">
|
||||
<div className={`${table.columns.length > 8 ? "max-h-64 overflow-y-auto" : ""}`}>
|
||||
{table.columns.map((column, index) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={`flex cursor-pointer items-center justify-between p-2 hover:bg-white ${
|
||||
index < table.columns.length - 1 ? "border-b border-gray-100" : ""
|
||||
className={`hover:bg-accent/50 flex cursor-grab items-center justify-between p-2 transition-colors ${
|
||||
index < table.columns.length - 1 ? "border-border/50 border-b" : ""
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table, column)}
|
||||
>
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{getWidgetIcon(column.widgetType)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<div className="truncate text-xs font-semibold">
|
||||
{column.columnLabel || column.columnName}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-500">{column.dataType}</div>
|
||||
<div className="text-muted-foreground truncate text-xs">{column.dataType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center space-x-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-xs">
|
||||
{column.widgetType}
|
||||
</Badge>
|
||||
{column.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Badge variant="destructive" className="h-4 px-1.5 text-xs">
|
||||
필수
|
||||
</Badge>
|
||||
)}
|
||||
|
|
@ -200,8 +190,8 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
|
||||
{/* 컬럼 수가 많을 때 안내 메시지 */}
|
||||
{table.columns.length > 8 && (
|
||||
<div className="sticky bottom-0 bg-gray-100 p-2 text-center">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="bg-muted sticky bottom-0 p-2 text-center">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
📜 총 {table.columns.length}개 컬럼 (스크롤하여 더 보기)
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,7 +207,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-xs text-muted-foreground">💡 테이블이나 컬럼을 캔버스로 드래그하세요</div>
|
||||
<div className="text-muted-foreground text-xs">💡 테이블이나 컬럼을 캔버스로 드래그하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
|||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||
import { ResponsiveConfigPanel } from "./ResponsiveConfigPanel";
|
||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
|
|
@ -487,6 +488,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
|
||||
// 테이블 패널에서 드래그한 컴포넌트인지 확인
|
||||
const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName);
|
||||
|
||||
if (!componentId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
|
|
@ -509,18 +513,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 컴포넌트 정보 */}
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<span className="text-sm font-medium text-green-900">컴포넌트: {componentId}</span>
|
||||
{webType && currentBaseInputType && (
|
||||
<div className="mt-1 text-xs text-green-700">입력 타입: {currentBaseInputType}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 세부 타입 선택 */}
|
||||
{webType && availableDetailTypes.length > 1 && (
|
||||
{/* 세부 타입 선택 - 테이블 패널에서 드래그한 컴포넌트만 표시 */}
|
||||
{isFromTablePanel && webType && availableDetailTypes.length > 1 && (
|
||||
<div>
|
||||
<Label>세부 타입 선택</Label>
|
||||
<Label>세부 타입</Label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="세부 타입 선택" />
|
||||
|
|
@ -536,7 +532,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">입력 타입 "{currentBaseInputType}"의 세부 형태를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -546,10 +541,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
config={selectedComponent.componentConfig || {}}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
tables={tables}
|
||||
onChange={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
|
||||
// 전체 componentConfig를 업데이트
|
||||
handleUpdate("componentConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -624,10 +620,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
config={widget.componentConfig || {}}
|
||||
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
tables={tables}
|
||||
onChange={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||||
// 전체 componentConfig를 업데이트
|
||||
handleUpdate("componentConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -711,6 +708,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<TabsTrigger value="basic">기본</TabsTrigger>
|
||||
<TabsTrigger value="detail">상세</TabsTrigger>
|
||||
<TabsTrigger value="data">데이터</TabsTrigger>
|
||||
<TabsTrigger value="responsive">반응형</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
|
@ -723,6 +721,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<TabsContent value="data" className="m-0 p-4">
|
||||
{renderDataTab()}
|
||||
</TabsContent>
|
||||
<TabsContent value="responsive" className="m-0 p-4">
|
||||
<ResponsiveConfigPanel
|
||||
component={selectedComponent}
|
||||
onUpdate={(config) => {
|
||||
onUpdateProperty(selectedComponent.id, "responsiveConfig", config);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, ArrowLeft, Undo, Redo, Play, Save, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Database, ArrowLeft, Save, Monitor, Smartphone } from "lucide-react";
|
||||
import { ScreenResolution } from "@/types/screen";
|
||||
|
||||
interface SlimToolbarProps {
|
||||
|
|
@ -12,12 +11,8 @@ interface SlimToolbarProps {
|
|||
screenResolution?: ScreenResolution;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSaving?: boolean;
|
||||
onPreview?: () => void;
|
||||
}
|
||||
|
||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
|
|
@ -26,12 +21,8 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
screenResolution,
|
||||
onBack,
|
||||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSaving = false,
|
||||
onPreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
|
||||
|
|
@ -71,37 +62,14 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼들 */}
|
||||
{/* 우측: 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">실행취소</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">다시실행</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span>미리보기</span>
|
||||
</Button>
|
||||
|
||||
{onPreview && (
|
||||
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
<span>반응형 미리보기</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||
|
|
|
|||
|
|
@ -21,28 +21,30 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
|
||||
// 전역 파일 상태 관리 함수들
|
||||
const getGlobalFileState = (): {[key: string]: any[]} => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const getGlobalFileState = (): { [key: string]: any[] } => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (window as any).globalFileState || {};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const setGlobalFileState = (updater: (prev: {[key: string]: any[]}) => {[key: string]: any[]}) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const setGlobalFileState = (updater: (prev: { [key: string]: any[] }) => { [key: string]: any[] }) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const currentState = getGlobalFileState();
|
||||
const newState = updater(currentState);
|
||||
(window as any).globalFileState = newState;
|
||||
|
||||
|
||||
// console.log("🌐 FileUpload 전역 파일 상태 업데이트:", {
|
||||
// componentId: component.id,
|
||||
// newFileCount: newState[component.id]?.length || 0
|
||||
// componentId: component.id,
|
||||
// newFileCount: newState[component.id]?.length || 0
|
||||
// });
|
||||
|
||||
|
||||
// 강제 리렌더링을 위한 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { componentId: component.id, fileCount: newState[component.id]?.length || 0 }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("globalFileStateChanged", {
|
||||
detail: { componentId: component.id, fileCount: newState[component.id]?.length || 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -51,14 +53,14 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||
const componentFiles = component.uploadedFiles || [];
|
||||
const finalFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||
|
||||
|
||||
// console.log("🚀 FileUpload 파일 상태 초기화:", {
|
||||
// componentId: component.id,
|
||||
// globalFiles: globalFiles.length,
|
||||
// componentFiles: componentFiles.length,
|
||||
// finalFiles: finalFiles.length
|
||||
// componentId: component.id,
|
||||
// globalFiles: globalFiles.length,
|
||||
// componentFiles: componentFiles.length,
|
||||
// finalFiles: finalFiles.length
|
||||
// });
|
||||
|
||||
|
||||
return finalFiles;
|
||||
};
|
||||
|
||||
|
|
@ -71,23 +73,23 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
if (event.detail.componentId === component.id) {
|
||||
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||
// console.log("🔄 FileUpload 전역 상태 변경 감지:", {
|
||||
// componentId: component.id,
|
||||
// newFileCount: globalFiles.length
|
||||
// componentId: component.id,
|
||||
// newFileCount: globalFiles.length
|
||||
// });
|
||||
setLocalUploadedFiles(globalFiles);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id]);
|
||||
|
||||
const { fileConfig } = component;
|
||||
const { fileConfig = {} } = component;
|
||||
const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기
|
||||
|
||||
// props로 받은 userInfo를 우선 사용, 없으면 useAuth에서 가져온 user 사용
|
||||
|
|
@ -102,16 +104,16 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
// 사용자 정보 디버깅
|
||||
useEffect(() => {
|
||||
// console.log("👤 File 컴포넌트 인증 상태 및 사용자 정보:", {
|
||||
// isLoading,
|
||||
// isLoggedIn,
|
||||
// hasUser: !!user,
|
||||
// user: user,
|
||||
// userId: user?.userId,
|
||||
// company_code: user?.company_code,
|
||||
// companyCode: user?.companyCode,
|
||||
// userType: typeof user,
|
||||
// userKeys: user ? Object.keys(user) : "no user",
|
||||
// userValues: user ? Object.entries(user) : "no user",
|
||||
// isLoading,
|
||||
// isLoggedIn,
|
||||
// hasUser: !!user,
|
||||
// user: user,
|
||||
// userId: user?.userId,
|
||||
// company_code: user?.company_code,
|
||||
// companyCode: user?.companyCode,
|
||||
// userType: typeof user,
|
||||
// userKeys: user ? Object.keys(user) : "no user",
|
||||
// userValues: user ? Object.entries(user) : "no user",
|
||||
// });
|
||||
|
||||
// 사용자 정보가 유효하면 initialUser와 userRef 업데이트
|
||||
|
|
@ -124,18 +126,18 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
// 회사 관련 필드들 확인
|
||||
if (user) {
|
||||
// console.log("🔍 회사 관련 필드 검색:", {
|
||||
// company_code: user.company_code,
|
||||
// companyCode: user.companyCode,
|
||||
// company: user.company,
|
||||
// deptCode: user.deptCode,
|
||||
// partnerCd: user.partnerCd,
|
||||
// 모든 필드에서 company 관련된 것들 찾기
|
||||
// allFields: Object.keys(user).filter(
|
||||
// (key) =>
|
||||
// key.toLowerCase().includes("company") ||
|
||||
// key.toLowerCase().includes("corp") ||
|
||||
// key.toLowerCase().includes("code"),
|
||||
// ),
|
||||
// company_code: user.company_code,
|
||||
// companyCode: user.companyCode,
|
||||
// company: user.company,
|
||||
// deptCode: user.deptCode,
|
||||
// partnerCd: user.partnerCd,
|
||||
// 모든 필드에서 company 관련된 것들 찾기
|
||||
// allFields: Object.keys(user).filter(
|
||||
// (key) =>
|
||||
// key.toLowerCase().includes("company") ||
|
||||
// key.toLowerCase().includes("corp") ||
|
||||
// key.toLowerCase().includes("code"),
|
||||
// ),
|
||||
// });
|
||||
} else {
|
||||
// console.warn("⚠️ 사용자 정보가 없습니다. 인증 상태 확인 필요");
|
||||
|
|
@ -145,8 +147,8 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
// 컴포넌트 props가 변경될 때 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
// console.log("🔄 File 컴포넌트 props 변경:", {
|
||||
// propsUploadedFiles: component.uploadedFiles?.length || 0,
|
||||
// localUploadedFiles: localUploadedFiles.length,
|
||||
// propsUploadedFiles: component.uploadedFiles?.length || 0,
|
||||
// localUploadedFiles: localUploadedFiles.length,
|
||||
// });
|
||||
setLocalUploadedFiles(component.uploadedFiles || []);
|
||||
}, [component.uploadedFiles]);
|
||||
|
|
@ -177,9 +179,9 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
const fileName = file.name.toLowerCase();
|
||||
|
||||
// console.log("🔍 파일 타입 검증:", {
|
||||
// fileName: file.name,
|
||||
// fileType: file.type,
|
||||
// acceptRules: fileConfig.accept,
|
||||
// fileName: file.name,
|
||||
// fileType: file.type,
|
||||
// acceptRules: fileConfig.accept,
|
||||
// });
|
||||
|
||||
const result = fileConfig.accept.some((accept) => {
|
||||
|
|
@ -225,11 +227,11 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
const errors: string[] = [];
|
||||
|
||||
// console.log("🔍 파일 검증 시작:", {
|
||||
// totalFiles: fileArray.length,
|
||||
// currentUploadedCount: uploadedFiles.length,
|
||||
// maxFiles: fileConfig.maxFiles,
|
||||
// maxSize: fileConfig.maxSize,
|
||||
// allowedTypes: fileConfig.accept,
|
||||
// totalFiles: fileArray.length,
|
||||
// currentUploadedCount: uploadedFiles.length,
|
||||
// maxFiles: fileConfig.maxFiles,
|
||||
// maxSize: fileConfig.maxSize,
|
||||
// allowedTypes: fileConfig.accept,
|
||||
// });
|
||||
|
||||
// 파일 검증
|
||||
|
|
@ -270,23 +272,23 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
// 유효한 파일들을 업로드 큐에 추가
|
||||
if (validFiles.length > 0) {
|
||||
// console.log(
|
||||
// "✅ 유효한 파일들 업로드 큐에 추가:",
|
||||
// validFiles.map((f) => f.name),
|
||||
// "✅ 유효한 파일들 업로드 큐에 추가:",
|
||||
// validFiles.map((f) => f.name),
|
||||
// );
|
||||
setUploadQueue((prev) => [...prev, ...validFiles]);
|
||||
|
||||
if (fileConfig.autoUpload) {
|
||||
// console.log("🚀 자동 업로드 시작:", {
|
||||
// autoUpload: fileConfig.autoUpload,
|
||||
// filesCount: validFiles.length,
|
||||
// fileNames: validFiles.map((f) => f.name),
|
||||
// autoUpload: fileConfig.autoUpload,
|
||||
// filesCount: validFiles.length,
|
||||
// fileNames: validFiles.map((f) => f.name),
|
||||
// });
|
||||
// 자동 업로드 실행
|
||||
validFiles.forEach(uploadFile);
|
||||
} else {
|
||||
// console.log("⏸️ 자동 업로드 비활성화:", {
|
||||
// autoUpload: fileConfig.autoUpload,
|
||||
// filesCount: validFiles.length,
|
||||
// autoUpload: fileConfig.autoUpload,
|
||||
// filesCount: validFiles.length,
|
||||
// });
|
||||
}
|
||||
} else {
|
||||
|
|
@ -312,18 +314,18 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
|
||||
// 실시간 사용자 정보 디버깅
|
||||
// console.log("🔍 FileUpload - uploadFile ref를 통한 실시간 상태:", {
|
||||
// hasCurrentUser: !!currentUser,
|
||||
// currentUser: currentUser
|
||||
// ? {
|
||||
// userId: currentUser.userId,
|
||||
// companyCode: currentUser.companyCode,
|
||||
// company_code: currentUser.company_code,
|
||||
// }
|
||||
// : null,
|
||||
// 기존 상태와 비교
|
||||
// originalUser: user,
|
||||
// originalInitialUser: initialUser,
|
||||
// refExists: !!userRef.current,
|
||||
// hasCurrentUser: !!currentUser,
|
||||
// currentUser: currentUser
|
||||
// ? {
|
||||
// userId: currentUser.userId,
|
||||
// companyCode: currentUser.companyCode,
|
||||
// company_code: currentUser.company_code,
|
||||
// }
|
||||
// : null,
|
||||
// 기존 상태와 비교
|
||||
// originalUser: user,
|
||||
// originalInitialUser: initialUser,
|
||||
// refExists: !!userRef.current,
|
||||
// });
|
||||
|
||||
// 사용자 정보가 로드되지 않은 경우 잠시 대기
|
||||
|
|
@ -351,15 +353,15 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
// console.log("✅ 회사코드 추가:", companyCode);
|
||||
} else {
|
||||
// console.warn("⚠️ 회사코드가 없음, DEFAULT 사용. 사용자 정보:", {
|
||||
// user: user,
|
||||
// initialUser: initialUser,
|
||||
// effectiveUser: effectiveUser,
|
||||
// companyCode: effectiveUser?.companyCode,
|
||||
// company_code: effectiveUser?.company_code,
|
||||
// deptCode: effectiveUser?.deptCode,
|
||||
// isLoading,
|
||||
// isLoggedIn,
|
||||
// allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user",
|
||||
// user: user,
|
||||
// initialUser: initialUser,
|
||||
// effectiveUser: effectiveUser,
|
||||
// companyCode: effectiveUser?.companyCode,
|
||||
// company_code: effectiveUser?.company_code,
|
||||
// deptCode: effectiveUser?.deptCode,
|
||||
// isLoading,
|
||||
// isLoggedIn,
|
||||
// allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user",
|
||||
// });
|
||||
formData.append("companyCode", "DEFAULT");
|
||||
}
|
||||
|
|
@ -499,28 +501,28 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
setLocalUploadedFiles(updatedFiles);
|
||||
|
||||
// 전역 상태 업데이트
|
||||
setGlobalFileState(prev => ({
|
||||
setGlobalFileState((prev) => ({
|
||||
...prev,
|
||||
[component.id]: updatedFiles
|
||||
[component.id]: updatedFiles,
|
||||
}));
|
||||
|
||||
// RealtimePreview 동기화를 위한 추가 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'upload',
|
||||
timestamp: Date.now()
|
||||
action: "upload",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
|
||||
// console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
|
||||
const event = new CustomEvent("globalFileStateChanged", {
|
||||
detail: eventDetail,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
|
||||
// console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료");
|
||||
}
|
||||
|
||||
|
|
@ -540,19 +542,19 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
setUploadQueue((prev) => prev.filter((f) => f !== file));
|
||||
} catch (error) {
|
||||
// console.error("❌ 파일 업로드 실패:", {
|
||||
// error,
|
||||
// errorMessage: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
// errorStack: error instanceof Error ? error.stack : undefined,
|
||||
// user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user",
|
||||
// authState: { isLoading, isLoggedIn },
|
||||
// error,
|
||||
// errorMessage: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
// errorStack: error instanceof Error ? error.stack : undefined,
|
||||
// user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user",
|
||||
// authState: { isLoading, isLoggedIn },
|
||||
// });
|
||||
|
||||
// API 응답 에러인 경우 상세 정보 출력
|
||||
if ((error as any)?.response) {
|
||||
// console.error("📡 API 응답 에러:", {
|
||||
// status: (error as any).response.status,
|
||||
// statusText: (error as any).response.statusText,
|
||||
// data: (error as any).response.data,
|
||||
// status: (error as any).response.status,
|
||||
// statusText: (error as any).response.statusText,
|
||||
// data: (error as any).response.data,
|
||||
// });
|
||||
}
|
||||
|
||||
|
|
@ -598,50 +600,54 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
setLocalUploadedFiles(filteredFiles);
|
||||
|
||||
// 전역 상태 업데이트
|
||||
setGlobalFileState(prev => ({
|
||||
setGlobalFileState((prev) => ({
|
||||
...prev,
|
||||
[component.id]: filteredFiles
|
||||
[component.id]: filteredFiles,
|
||||
}));
|
||||
|
||||
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: filteredFiles,
|
||||
fileCount: filteredFiles.length,
|
||||
action: 'delete',
|
||||
action: "delete",
|
||||
timestamp: Date.now(),
|
||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||
source: "realScreen", // 실제 화면에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
|
||||
// console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
|
||||
const event = new CustomEvent("globalFileStateChanged", {
|
||||
detail: eventDetail,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
|
||||
// console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||
|
||||
|
||||
// 추가 지연 이벤트들
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("globalFileStateChanged", {
|
||||
detail: { ...eventDetail, delayed: true },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("globalFileStateChanged", {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
||||
}
|
||||
|
|
@ -704,8 +710,8 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
className={`group relative rounded-2xl border-2 border-dashed p-10 text-center transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "border-primary bg-gradient-to-br from-blue-100/90 to-indigo-100/80 shadow-xl shadow-blue-500/20 scale-105"
|
||||
isDragOver
|
||||
? "border-primary scale-105 bg-gradient-to-br from-blue-100/90 to-indigo-100/80 shadow-xl shadow-blue-500/20"
|
||||
: "border-gray-300/60 bg-gradient-to-br from-gray-50/80 to-blue-50/40 hover:border-blue-400/80 hover:bg-gradient-to-br hover:from-blue-50/90 hover:to-indigo-50/60 hover:shadow-lg hover:shadow-blue-500/10"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
|
|
@ -713,59 +719,61 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="relative">
|
||||
<Upload className={`mx-auto mb-4 h-16 w-16 transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "text-blue-500 scale-110"
|
||||
: "text-gray-400 group-hover:text-blue-500 group-hover:scale-105"
|
||||
}`} />
|
||||
<Upload
|
||||
className={`mx-auto mb-4 h-16 w-16 transition-all duration-300 ${
|
||||
isDragOver ? "scale-110 text-blue-500" : "text-gray-400 group-hover:scale-105 group-hover:text-blue-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className={`h-20 w-20 rounded-full transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "bg-blue-200/80 scale-110"
|
||||
: "bg-primary/20/50 opacity-0 group-hover:opacity-100 group-hover:scale-110"
|
||||
}`}></div>
|
||||
<div
|
||||
className={`h-20 w-20 rounded-full transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "scale-110 bg-blue-200/80"
|
||||
: "bg-primary/20/50 opacity-0 group-hover:scale-110 group-hover:opacity-100"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mb-2 text-xl font-semibold transition-colors duration-300 ${
|
||||
isDragOver
|
||||
? "text-primary"
|
||||
: "text-gray-700 group-hover:text-primary"
|
||||
}`}>
|
||||
<p
|
||||
className={`mb-2 text-xl font-semibold transition-colors duration-300 ${
|
||||
isDragOver ? "text-primary" : "group-hover:text-primary text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}
|
||||
</p>
|
||||
<p className={`mb-4 text-sm transition-colors duration-300 ${
|
||||
isDragOver
|
||||
? "text-blue-500"
|
||||
: "text-gray-500 group-hover:text-blue-500"
|
||||
}`}>
|
||||
<p
|
||||
className={`mb-4 text-sm transition-colors duration-300 ${
|
||||
isDragOver ? "text-blue-500" : "text-gray-500 group-hover:text-blue-500"
|
||||
}`}
|
||||
>
|
||||
또는 클릭하여 파일을 선택하세요
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFileInputClick}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFileInputClick}
|
||||
className={`mb-4 rounded-xl border-2 transition-all duration-200 ${
|
||||
isDragOver
|
||||
? "border-blue-400 bg-accent text-primary shadow-md"
|
||||
: "border-gray-300/60 hover:border-blue-400/60 hover:bg-accent/50 hover:shadow-sm"
|
||||
isDragOver
|
||||
? "bg-accent text-primary border-blue-400 shadow-md"
|
||||
: "hover:bg-accent/50 border-gray-300/60 hover:border-blue-400/60 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{fileConfig.uploadButtonText || "파일 선택"}
|
||||
{fileConfig?.uploadButtonText || "파일 선택"}
|
||||
</Button>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
<p>허용 파일: {fileConfig.accept.join(", ")}</p>
|
||||
<p>허용 파일: {(fileConfig?.accept || ["*/*"]).join(", ")}</p>
|
||||
<p>
|
||||
최대 크기: {fileConfig.maxSize}MB | 최대 개수: {fileConfig.maxFiles}개
|
||||
최대 크기: {fileConfig?.maxSize || 10}MB | 최대 개수: {fileConfig?.maxFiles || 5}개
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={fileConfig.multiple}
|
||||
accept={fileConfig.accept.join(",")}
|
||||
multiple={fileConfig?.multiple !== false}
|
||||
accept={(fileConfig?.accept || ["*/*"]).join(",")}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
|
@ -774,44 +782,51 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
{/* 업로드된 파일 목록 */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-gradient-to-r from-blue-50/80 to-indigo-50/60 px-4 py-3 border border-primary/20/40">
|
||||
<div className="border-primary/20/40 flex items-center justify-between rounded-xl border bg-gradient-to-r from-blue-50/80 to-indigo-50/60 px-4 py-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20">
|
||||
<File className="h-4 w-4 text-primary" />
|
||||
<div className="bg-primary/20 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<File className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800">
|
||||
업로드된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="rounded-lg border-primary/20/60 bg-white/80 hover:bg-accent/80">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/20/60 hover:bg-accent/80 rounded-lg bg-white/80"
|
||||
>
|
||||
자세히보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{uploadedFiles.map((fileInfo) => (
|
||||
<div key={fileInfo.objid} className="group relative flex items-center justify-between rounded-xl border border-gray-200/60 bg-white/90 p-4 shadow-sm transition-all duration-200 hover:shadow-md hover:border-blue-300/60 hover:bg-accent/30">
|
||||
<div
|
||||
key={fileInfo.objid}
|
||||
className="group hover:bg-accent/30 relative flex items-center justify-between rounded-xl border border-gray-200/60 bg-white/90 p-4 shadow-sm transition-all duration-200 hover:border-blue-300/60 hover:shadow-md"
|
||||
>
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-gray-50 to-gray-100/80 shadow-sm">
|
||||
{getFileIcon(fileInfo.fileExt)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-semibold text-gray-800 group-hover:text-primary transition-colors duration-200">
|
||||
<p className="group-hover:text-primary truncate text-base font-semibold text-gray-800 transition-colors duration-200">
|
||||
{fileInfo.realFileName}
|
||||
</p>
|
||||
<div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
||||
<div className="mt-1 flex items-center space-x-3 text-sm text-gray-500">
|
||||
<span className="flex items-center space-x-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-400"></div>
|
||||
<span className="font-medium">{formatFileSize(fileInfo.fileSize)}</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-gray-400"></div>
|
||||
<span className="px-2 py-1 rounded-md bg-gray-100 text-xs font-medium">
|
||||
<span className="rounded-md bg-gray-100 px-2 py-1 text-xs font-medium">
|
||||
{fileInfo.fileExt.toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -835,7 +850,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
|
||||
{/* 에러 메시지 */}
|
||||
{fileInfo.hasError && (
|
||||
<div className="mt-2 flex items-center space-x-2 rounded-md bg-destructive/10 p-2 text-sm text-red-700">
|
||||
<div className="bg-destructive/10 mt-2 flex items-center space-x-2 rounded-md p-2 text-sm text-red-700">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{fileInfo.errorMessage}</span>
|
||||
</div>
|
||||
|
|
@ -846,9 +861,9 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
<div className="flex items-center space-x-2">
|
||||
{/* 상태 표시 */}
|
||||
{fileInfo.isUploading && (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-accent px-3 py-2">
|
||||
<div className="bg-accent flex items-center space-x-2 rounded-lg px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
<span className="text-xs font-medium text-primary">업로드 중...</span>
|
||||
<span className="text-primary text-xs font-medium">업로드 중...</span>
|
||||
</div>
|
||||
)}
|
||||
{fileInfo.status === "ACTIVE" && (
|
||||
|
|
@ -858,9 +873,9 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
</div>
|
||||
)}
|
||||
{fileInfo.hasError && (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-destructive/10 px-3 py-2">
|
||||
<div className="bg-destructive/10 flex items-center space-x-2 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs font-medium text-destructive">오류</span>
|
||||
<span className="text-destructive text-xs font-medium">오류</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -868,21 +883,21 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
{!fileInfo.isUploading && !fileInfo.hasError && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{fileConfig.showPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => previewFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg hover:bg-accent hover:text-primary transition-all duration-200"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => previewFile(fileInfo)}
|
||||
className="hover:bg-accent hover:text-primary h-9 w-9 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => previewFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg hover:bg-green-50 hover:text-green-600 transition-all duration-200"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => previewFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg transition-all duration-200 hover:bg-green-50 hover:text-green-600"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -891,7 +906,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg text-red-500 hover:bg-destructive/10 hover:text-red-700 transition-all duration-200"
|
||||
className="hover:bg-destructive/10 h-9 w-9 rounded-lg text-red-500 transition-all duration-200 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -905,7 +920,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
)}
|
||||
|
||||
{/* 문서 타입 정보 */}
|
||||
<div className="flex items-center justify-center space-x-2 rounded-xl bg-gradient-to-r from-amber-50/80 to-orange-50/60 border border-amber-200/40 px-4 py-3">
|
||||
<div className="flex items-center justify-center space-x-2 rounded-xl border border-amber-200/40 bg-gradient-to-r from-amber-50/80 to-orange-50/60 px-4 py-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-100">
|
||||
<File className="h-3 w-3 text-amber-600" />
|
||||
|
|
@ -914,7 +929,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-white/80 border-amber-200/60 text-amber-700">
|
||||
<Badge variant="outline" className="border-amber-200/60 bg-white/80 text-amber-700">
|
||||
{fileConfig.docTypeName}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ export default function InputWidget({ widget, value, onChange, className }: Inpu
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{widget.label && (
|
||||
<Label htmlFor={widget.id} className="text-sm font-medium">
|
||||
<Label htmlFor={widget.id} className="text-xs font-medium">
|
||||
{widget.label}
|
||||
{widget.required && <span className="ml-1 text-red-500">*</span>}
|
||||
{widget.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<Input
|
||||
|
|
@ -33,7 +33,7 @@ export default function InputWidget({ widget, value, onChange, className }: Inpu
|
|||
onChange={handleChange}
|
||||
required={widget.required}
|
||||
readOnly={widget.readonly}
|
||||
className={cn("w-full", widget.readonly && "cursor-not-allowed bg-gray-50")}
|
||||
className={cn("h-9 w-full text-sm", widget.readonly && "bg-muted/50 cursor-not-allowed")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,20 +45,20 @@ export default function SelectWidget({ widget, value, onChange, options = [], cl
|
|||
const displayOptions = options.length > 0 ? options : getDefaultOptions();
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{widget.label && (
|
||||
<Label htmlFor={widget.id} className="text-sm font-medium">
|
||||
<Label htmlFor={widget.id} className="text-xs font-medium">
|
||||
{widget.label}
|
||||
{widget.required && <span className="ml-1 text-red-500">*</span>}
|
||||
{widget.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<Select value={value} onValueChange={handleChange} disabled={widget.readonly}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<SelectValue placeholder={widget.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{displayOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value} className="text-sm">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ export default function TextareaWidget({ widget, value, onChange, className }: T
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{widget.label && (
|
||||
<Label htmlFor={widget.id} className="text-sm font-medium">
|
||||
<Label htmlFor={widget.id} className="text-xs font-medium">
|
||||
{widget.label}
|
||||
{widget.required && <span className="ml-1 text-red-500">*</span>}
|
||||
{widget.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<Textarea
|
||||
|
|
@ -32,7 +32,7 @@ export default function TextareaWidget({ widget, value, onChange, className }: T
|
|||
onChange={handleChange}
|
||||
required={widget.required}
|
||||
readOnly={widget.readonly}
|
||||
className={cn("min-h-[100px] w-full", widget.readonly && "cursor-not-allowed bg-gray-50")}
|
||||
className={cn("min-h-[80px] w-full text-sm", widget.readonly && "bg-muted/50 cursor-not-allowed")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,513 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||
|
||||
export interface RepeaterInputProps {
|
||||
value?: RepeaterData;
|
||||
onChange?: (value: RepeaterData) => void;
|
||||
config?: RepeaterFieldGroupConfig;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 필드 그룹 컴포넌트
|
||||
* 여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 컴포넌트
|
||||
*/
|
||||
export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
config = { fields: [] },
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
className,
|
||||
}) => {
|
||||
// 현재 브레이크포인트 감지
|
||||
const globalBreakpoint = useBreakpoint();
|
||||
const previewBreakpoint = usePreviewBreakpoint();
|
||||
|
||||
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용
|
||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 설정 기본값
|
||||
const {
|
||||
fields = [],
|
||||
minItems = 0,
|
||||
maxItems = 10,
|
||||
addButtonText = "항목 추가",
|
||||
allowReorder = true,
|
||||
showIndex = true,
|
||||
collapsible = false,
|
||||
layout = "grid", // 기본값을 grid로 설정
|
||||
showDivider = true,
|
||||
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
|
||||
} = config;
|
||||
|
||||
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
|
||||
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
|
||||
|
||||
// 로컬 상태 관리
|
||||
const [items, setItems] = useState<RepeaterData>(
|
||||
value.length > 0
|
||||
? value
|
||||
: minItems > 0
|
||||
? Array(minItems)
|
||||
.fill(null)
|
||||
.map(() => createEmptyItem())
|
||||
: [],
|
||||
);
|
||||
|
||||
// 접힌 상태 관리 (각 항목별)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
// 빈 항목 생성
|
||||
function createEmptyItem(): RepeaterItemData {
|
||||
const item: RepeaterItemData = {};
|
||||
fields.forEach((field) => {
|
||||
item[field.name] = "";
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
// 외부 value 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
setItems(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 항목 추가
|
||||
const handleAddItem = () => {
|
||||
if (items.length >= maxItems) {
|
||||
return;
|
||||
}
|
||||
const newItems = [...items, createEmptyItem()];
|
||||
setItems(newItems);
|
||||
console.log("➕ RepeaterInput 항목 추가, onChange 호출:", newItems);
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: newItems;
|
||||
|
||||
onChange?.(dataWithMeta);
|
||||
};
|
||||
|
||||
// 항목 제거
|
||||
const handleRemoveItem = (index: number) => {
|
||||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: newItems;
|
||||
|
||||
onChange?.(dataWithMeta);
|
||||
|
||||
// 접힌 상태도 업데이트
|
||||
const newCollapsed = new Set(collapsedItems);
|
||||
newCollapsed.delete(index);
|
||||
setCollapsedItems(newCollapsed);
|
||||
};
|
||||
|
||||
// 필드 값 변경
|
||||
const handleFieldChange = (itemIndex: number, fieldName: string, value: any) => {
|
||||
const newItems = [...items];
|
||||
newItems[itemIndex] = {
|
||||
...newItems[itemIndex],
|
||||
[fieldName]: value,
|
||||
};
|
||||
setItems(newItems);
|
||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||
itemIndex,
|
||||
fieldName,
|
||||
value,
|
||||
newItems,
|
||||
});
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: newItems;
|
||||
|
||||
onChange?.(dataWithMeta);
|
||||
};
|
||||
|
||||
// 접기/펼치기 토글
|
||||
const toggleCollapse = (index: number) => {
|
||||
const newCollapsed = new Set(collapsedItems);
|
||||
if (newCollapsed.has(index)) {
|
||||
newCollapsed.delete(index);
|
||||
} else {
|
||||
newCollapsed.add(index);
|
||||
}
|
||||
setCollapsedItems(newCollapsed);
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 (순서 변경)
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
if (!allowReorder || readonly || disabled) return;
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
if (!allowReorder || readonly || disabled) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
if (!allowReorder || readonly || disabled || draggedIndex === null) return;
|
||||
e.preventDefault();
|
||||
|
||||
const newItems = [...items];
|
||||
const draggedItem = newItems[draggedIndex];
|
||||
newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, draggedItem);
|
||||
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: disabled || readonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="number"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
/>
|
||||
);
|
||||
|
||||
case "email":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="email"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="tel"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="text"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
maxLength={field.validation?.maxLength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드가 정의되지 않았을 때
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-orange-300 bg-orange-50 p-8 text-center">
|
||||
<p className="text-sm font-medium text-orange-900">필드가 정의되지 않았습니다</p>
|
||||
<p className="mt-2 text-xs text-orange-700">속성 패널에서 필드를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태 렌더링
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<p className="mb-4 text-sm text-gray-500">{emptyMessage}</p>
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" onClick={handleAddItem} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 카드 레이아웃일 때 필드 배치 (세로로 나열)
|
||||
const getFieldsLayoutClass = () => {
|
||||
return "space-y-3";
|
||||
};
|
||||
|
||||
// 그리드/테이블 형식 렌더링
|
||||
const renderGridLayout = () => {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
{/* 테이블 헤더 */}
|
||||
<div
|
||||
className="grid gap-2 border-b bg-gray-50 p-3 font-semibold"
|
||||
style={{
|
||||
gridTemplateColumns: `40px ${allowReorder ? "40px " : ""}${fields.map(() => "1fr").join(" ")} 80px`,
|
||||
}}
|
||||
>
|
||||
{showIndex && <div className="text-center text-sm">#</div>}
|
||||
{allowReorder && <div className="text-center text-sm"></div>}
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="text-sm text-gray-700">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center text-sm">작업</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
<div className="divide-y">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"grid gap-2 p-3 transition-colors hover:bg-gray-50",
|
||||
draggedIndex === itemIndex && "bg-blue-50 opacity-50",
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `40px ${allowReorder ? "40px " : ""}${fields.map(() => "1fr").join(" ")} 80px`,
|
||||
}}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
onDragStart={() => handleDragStart(itemIndex)}
|
||||
onDragOver={(e) => handleDragOver(e, itemIndex)}
|
||||
onDrop={(e) => handleDrop(e, itemIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<div className="flex items-center justify-center text-sm font-medium text-gray-600">
|
||||
{itemIndex + 1}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<div className="flex items-center justify-center">
|
||||
<GripVertical className="h-4 w-4 cursor-move text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드들 */}
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center">
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div className="flex items-center justify-center">
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(itemIndex)}
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 카드 형식 렌더링 (기존 방식)
|
||||
const renderCardLayout = () => {
|
||||
return (
|
||||
<>
|
||||
{items.map((item, itemIndex) => {
|
||||
const isCollapsed = collapsible && collapsedItems.has(itemIndex);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"relative transition-all",
|
||||
draggedIndex === itemIndex && "opacity-50",
|
||||
isCollapsed && "shadow-sm",
|
||||
)}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
onDragStart={() => handleDragStart(itemIndex)}
|
||||
onDragOver={(e) => handleDragOver(e, itemIndex)}
|
||||
onDrop={(e) => handleDrop(e, itemIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-gray-400" />
|
||||
)}
|
||||
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<CardTitle className="text-sm font-semibold text-gray-700">항목 {itemIndex + 1}</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 접기/펼치기 버튼 */}
|
||||
{collapsible && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleCollapse(itemIndex)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(itemIndex)}
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
<div className={getFieldsLayoutClass()}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</label>
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{showDivider && itemIndex < items.length - 1 && <Separator className="mt-4" />}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 레이아웃에 따라 렌더링 방식 선택 (반응형 고려) */}
|
||||
{effectiveLayout === "grid" ? renderGridLayout() : renderCardLayout()}
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddItem} className="w-full border-dashed">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 제한 안내 */}
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>현재: {items.length}개 항목</span>
|
||||
<span>
|
||||
(최소: {minItems}, 최대: {maxItems})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RepeaterInput.displayName = "RepeaterInput";
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RepeaterConfigPanelProps {
|
||||
config: RepeaterFieldGroupConfig;
|
||||
onChange: (config: RepeaterFieldGroupConfig) => void;
|
||||
tableColumns?: ColumnInfo[];
|
||||
allTables?: Array<{ tableName: string; displayName?: string }>; // 전체 테이블 목록
|
||||
onTableChange?: (tableName: string) => void; // 테이블 변경 시 해당 테이블의 컬럼 로드
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 필드 그룹 설정 패널
|
||||
*/
|
||||
export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
allTables = [],
|
||||
onTableChange,
|
||||
}) => {
|
||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
// 이미 사용된 컬럼명 목록
|
||||
const usedColumnNames = useMemo(() => {
|
||||
return new Set(localFields.map((f) => f.name));
|
||||
}, [localFields]);
|
||||
|
||||
// 사용 가능한 컬럼 목록 (이미 사용된 컬럼 제외, 현재 편집 중인 필드는 포함)
|
||||
const getAvailableColumns = (currentFieldName?: string) => {
|
||||
return tableColumns.filter((col) => !usedColumnNames.has(col.columnName) || col.columnName === currentFieldName);
|
||||
};
|
||||
|
||||
const handleChange = (key: keyof RepeaterFieldGroupConfig, value: any) => {
|
||||
onChange({
|
||||
...config,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldsChange = (fields: RepeaterFieldDefinition[]) => {
|
||||
setLocalFields(fields);
|
||||
handleChange("fields", fields);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
const newField: RepeaterFieldDefinition = {
|
||||
name: `field_${localFields.length + 1}`,
|
||||
label: `필드 ${localFields.length + 1}`,
|
||||
type: "text",
|
||||
};
|
||||
handleFieldsChange([...localFields, newField]);
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
const updateField = (index: number, updates: Partial<RepeaterFieldDefinition>) => {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
handleFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 테이블 선택 Combobox 상태
|
||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||
const [tableSearchValue, setTableSearchValue] = useState("");
|
||||
|
||||
// 필터링된 테이블 목록
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!tableSearchValue) return allTables;
|
||||
const searchLower = tableSearchValue.toLowerCase();
|
||||
return allTables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchLower) || table.displayName?.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [allTables, tableSearchValue]);
|
||||
|
||||
// 선택된 테이블 표시명
|
||||
const selectedTableLabel = useMemo(() => {
|
||||
if (!config.targetTable) return "테이블을 선택하세요";
|
||||
const table = allTables.find((t) => t.tableName === config.targetTable);
|
||||
return table ? table.displayName || table.tableName : config.targetTable;
|
||||
}, [config.targetTable, allTables]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 대상 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">저장 대상 테이블</Label>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedTableLabel}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." value={tableSearchValue} onValueChange={setTableSearchValue} />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
{filteredTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(currentValue) => {
|
||||
handleChange("targetTable", currentValue);
|
||||
setTableSelectOpen(false);
|
||||
setTableSearchValue("");
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
if (onTableChange) {
|
||||
onTableChange(currentValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">반복 필드 데이터를 저장할 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 필드 정의 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<Card key={index} className="border-2">
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">필드 {index + 1}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(index)}
|
||||
className="h-6 w-6 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">필드명 (컬럼)</Label>
|
||||
<Popover
|
||||
open={fieldNamePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: open })}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{field.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandEmpty>사용 가능한 컬럼이 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{getAvailableColumns(field.name).map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: (column.widgetType as RepeaterFieldType) || "text",
|
||||
});
|
||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
field.name === column.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel}</div>
|
||||
<div className="text-gray-500">{column.columnName}</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
placeholder="필드 라벨"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타입</Label>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) => updateField(index, { type: value as RepeaterFieldType })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택박스</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={field.placeholder || ""}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
placeholder="입력 안내"
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addField} className="w-full border-dashed">
|
||||
<Plus className="mr-2 h-3 w-3" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 항목 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">항목 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-min-items" className="text-xs">
|
||||
최소 항목 수
|
||||
</Label>
|
||||
<Input
|
||||
id="repeater-min-items"
|
||||
type="number"
|
||||
min={0}
|
||||
max={config.maxItems || 100}
|
||||
value={config.minItems || 0}
|
||||
onChange={(e) => handleChange("minItems", parseInt(e.target.value) || 0)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-max-items" className="text-xs">
|
||||
최대 항목 수
|
||||
</Label>
|
||||
<Input
|
||||
id="repeater-max-items"
|
||||
type="number"
|
||||
min={config.minItems || 0}
|
||||
max={100}
|
||||
value={config.maxItems || 10}
|
||||
onChange={(e) => handleChange("maxItems", parseInt(e.target.value) || 10)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-add-button-text" className="text-xs">
|
||||
추가 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="repeater-add-button-text"
|
||||
type="text"
|
||||
value={config.addButtonText || ""}
|
||||
onChange={(e) => handleChange("addButtonText", e.target.value)}
|
||||
placeholder="항목 추가"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">레이아웃</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-layout" className="text-xs">
|
||||
표시 형식
|
||||
</Label>
|
||||
<Select
|
||||
value={config.layout || "grid"}
|
||||
onValueChange={(value) => handleChange("layout", value as "grid" | "card")}
|
||||
>
|
||||
<SelectTrigger id="repeater-layout" className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">테이블 형식 (Grid/Table)</SelectItem>
|
||||
<SelectItem value="card">카드 형식 (Card)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config.layout === "grid"
|
||||
? "행 단위로 데이터를 표시합니다 (테이블 형태)"
|
||||
: "각 항목을 카드로 표시합니다 (접기/펼치기 가능)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-allow-reorder"
|
||||
checked={config.allowReorder ?? true}
|
||||
onCheckedChange={(checked) => handleChange("allowReorder", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-allow-reorder" className="cursor-pointer text-xs font-normal">
|
||||
순서 변경 허용 (드래그 앤 드롭)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-show-index"
|
||||
checked={config.showIndex ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showIndex", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-show-index" className="cursor-pointer text-xs font-normal">
|
||||
항목 번호 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.layout === "card" && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-collapsible"
|
||||
checked={config.collapsible ?? false}
|
||||
onCheckedChange={(checked) => handleChange("collapsible", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-collapsible" className="cursor-pointer text-xs font-normal">
|
||||
항목 접기/펼치기 가능 (카드 모드 전용)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-show-divider"
|
||||
checked={config.showDivider ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showDivider", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-show-divider" className="cursor-pointer text-xs font-normal">
|
||||
항목 구분선 표시 (카드 모드 전용)
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-sm">
|
||||
<p className="mb-1 font-medium text-blue-900">💡 사용 예시</p>
|
||||
<ul className="space-y-1 text-xs text-blue-700">
|
||||
<li>• 담당자 정보 (이름, 전화번호, 이메일)</li>
|
||||
<li>• 학력 정보 (학교명, 전공, 졸업년도)</li>
|
||||
<li>• 경력 사항 (회사명, 직책, 기간)</li>
|
||||
<li>• 주소 목록 (주소, 우편번호, 상세주소)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RepeaterConfigPanel.displayName = "RepeaterConfigPanel";
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* 반응형 브레이크포인트 감지 훅
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
|
||||
|
||||
/**
|
||||
* 현재 윈도우 크기에 따른 브레이크포인트 반환
|
||||
*/
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop");
|
||||
|
||||
useEffect(() => {
|
||||
const updateBreakpoint = () => {
|
||||
const width = window.innerWidth;
|
||||
|
||||
if (width >= BREAKPOINTS.desktop.minWidth) {
|
||||
setBreakpoint("desktop");
|
||||
} else if (width >= BREAKPOINTS.tablet.minWidth) {
|
||||
setBreakpoint("tablet");
|
||||
} else {
|
||||
setBreakpoint("mobile");
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 실행
|
||||
updateBreakpoint();
|
||||
|
||||
// 리사이즈 이벤트 리스너 등록
|
||||
window.addEventListener("resize", updateBreakpoint);
|
||||
|
||||
return () => window.removeEventListener("resize", updateBreakpoint);
|
||||
}, []);
|
||||
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 브레이크포인트의 컬럼 수 반환
|
||||
*/
|
||||
export function useGridColumns(): number {
|
||||
const breakpoint = useBreakpoint();
|
||||
return BREAKPOINTS[breakpoint].columns;
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
/**
|
||||
* 데이터 조회 API
|
||||
*/
|
||||
export const dataApi = {
|
||||
/**
|
||||
* 테이블 데이터 조회
|
||||
* @param tableName 테이블명
|
||||
* @param params 조회 파라미터 (검색, 페이징 등)
|
||||
*/
|
||||
getTableData: async (
|
||||
tableName: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
searchTerm?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
filters?: Record<string, any>;
|
||||
},
|
||||
): Promise<{
|
||||
data: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/data/${tableName}`, { params });
|
||||
const raw = response.data || {};
|
||||
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
||||
|
||||
const page = raw.page ?? params?.page ?? 1;
|
||||
const size = raw.size ?? params?.size ?? items.length;
|
||||
const total = raw.total ?? items.length;
|
||||
const totalPages = raw.totalPages ?? Math.max(1, Math.ceil(total / (size || 1)));
|
||||
|
||||
return { data: items, total, page, size, totalPages };
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 레코드 상세 조회
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
*/
|
||||
getRecordDetail: async (tableName: string, id: string | number): Promise<any> => {
|
||||
const response = await apiClient.get(`/data/${tableName}/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
* @param leftTable 좌측 테이블명
|
||||
* @param rightTable 우측 테이블명
|
||||
* @param leftColumn 좌측 컬럼명
|
||||
* @param rightColumn 우측 컬럼명 (외래키)
|
||||
* @param leftValue 좌측 값 (필터링)
|
||||
*/
|
||||
getJoinedData: async (
|
||||
leftTable: string,
|
||||
rightTable: string,
|
||||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftValue?: any,
|
||||
): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/data/join`, {
|
||||
params: {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
},
|
||||
});
|
||||
const raw = response.data || {};
|
||||
return (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 생성
|
||||
* @param tableName 테이블명
|
||||
* @param data 레코드 데이터
|
||||
*/
|
||||
createRecord: async (tableName: string, data: Record<string, any>): Promise<any> => {
|
||||
const response = await apiClient.post(`/data/${tableName}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 수정
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
* @param data 수정할 데이터
|
||||
*/
|
||||
updateRecord: async (tableName: string, id: string | number, data: Record<string, any>): Promise<any> => {
|
||||
const response = await apiClient.put(`/data/${tableName}/${id}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 삭제
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
*/
|
||||
deleteRecord: async (tableName: string, id: string | number): Promise<void> => {
|
||||
await apiClient.delete(`/data/${tableName}/${id}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -64,6 +64,14 @@ export const screenApi = {
|
|||
return response.data.data;
|
||||
},
|
||||
|
||||
// 화면 정보 수정 (메타데이터만)
|
||||
updateScreenInfo: async (
|
||||
screenId: number,
|
||||
data: { screenName: string; description?: string; isActive: string },
|
||||
): Promise<void> => {
|
||||
await apiClient.put(`/screen-management/screens/${screenId}/info`, data);
|
||||
},
|
||||
|
||||
// 화면 의존성 체크
|
||||
checkScreenDependencies: async (
|
||||
screenId: number,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ export const componentRegistry = legacyComponentRegistry;
|
|||
export interface DynamicComponentRendererProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
isPreview?: boolean; // 반응형 모드 플래그
|
||||
isDesignMode?: boolean; // 디자인 모드 여부 (false일 때 데이터 로드)
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
|
|
@ -105,6 +107,7 @@ export interface DynamicComponentRendererProps {
|
|||
export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
isPreview = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
|
|
@ -191,47 +194,93 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
autoGeneration: component.autoGeneration,
|
||||
hidden: component.hidden,
|
||||
isInteractive,
|
||||
isPreview, // 반응형 모드 플래그
|
||||
isDesignMode: props.isDesignMode, // 디자인 모드 플래그
|
||||
});
|
||||
|
||||
return (
|
||||
<NewComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
size={component.size || newComponent.defaultSize}
|
||||
position={component.position}
|
||||
style={component.style}
|
||||
config={component.componentConfig}
|
||||
componentConfig={component.componentConfig}
|
||||
value={currentValue} // formData에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
autoGeneration={component.autoGeneration}
|
||||
hidden={component.hidden}
|
||||
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
|
||||
isInteractive={isInteractive}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
tableName={tableName}
|
||||
onRefresh={onRefresh}
|
||||
onClose={onClose}
|
||||
screenId={screenId}
|
||||
mode={mode}
|
||||
isInModal={isInModal}
|
||||
originalData={originalData}
|
||||
allComponents={allComponents}
|
||||
onUpdateLayout={onUpdateLayout}
|
||||
onZoneClick={onZoneClick}
|
||||
// 테이블 선택된 행 정보 전달
|
||||
selectedRows={selectedRows}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
// 설정 변경 핸들러 전달
|
||||
onConfigChange={onConfigChange}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
console.log("🔄 DynamicComponentRenderer handleChange 호출:", {
|
||||
componentType,
|
||||
fieldName,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
isArray: Array.isArray(value),
|
||||
});
|
||||
|
||||
if (onFormDataChange) {
|
||||
// RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달
|
||||
// 단순 input 컴포넌트는 (fieldName, value) 형태로 전달받음
|
||||
if (componentType === "repeater-field-group" || componentType === "repeater") {
|
||||
// fieldName과 함께 전달
|
||||
console.log("💾 RepeaterInput 데이터 저장:", fieldName, value);
|
||||
onFormDataChange(fieldName, value);
|
||||
} else {
|
||||
// 이미 fieldName이 포함된 경우는 그대로 전달
|
||||
onFormDataChange(fieldName, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 렌더러 props 구성
|
||||
const rendererProps = {
|
||||
component,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: component.style,
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
value: currentValue, // formData에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
autoGeneration: component.autoGeneration,
|
||||
hidden: component.hidden,
|
||||
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
|
||||
isInteractive,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
||||
tableName,
|
||||
onRefresh,
|
||||
onClose,
|
||||
screenId,
|
||||
mode,
|
||||
isInModal,
|
||||
readonly: component.readonly,
|
||||
disabled: component.readonly,
|
||||
originalData,
|
||||
allComponents,
|
||||
onUpdateLayout,
|
||||
onZoneClick,
|
||||
// 테이블 선택된 행 정보 전달
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
// 설정 변경 핸들러 전달
|
||||
onConfigChange,
|
||||
refreshKey,
|
||||
// 반응형 모드 플래그 전달
|
||||
isPreview,
|
||||
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분
|
||||
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
if (
|
||||
typeof NewComponentRenderer === "function" &&
|
||||
NewComponentRenderer.prototype &&
|
||||
NewComponentRenderer.prototype.render
|
||||
) {
|
||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||
return rendererInstance.render();
|
||||
} else {
|
||||
// 함수형 컴포넌트
|
||||
return <NewComponentRenderer {...rendererProps} />;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||
|
|
|
|||
|
|
@ -107,11 +107,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
if (!webTypeDefinition.isActive) {
|
||||
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4">
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<span className="text-sm font-medium">⚠️ 비활성화된 웹타입</span>
|
||||
<div className="rounded-md border border-dashed border-yellow-500/30 bg-yellow-50 p-3 dark:bg-yellow-950/20">
|
||||
<div className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
|
||||
<span className="text-xs font-medium">⚠️ 비활성화된 웹타입</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-yellow-500">웹타입 "{webType}"이 비활성화되어 있습니다.</p>
|
||||
<p className="mt-1 text-xs text-yellow-600 dark:text-yellow-500">
|
||||
웹타입 "{webType}"이 비활성화되어 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -163,11 +165,11 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
} catch (error) {
|
||||
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<span className="text-sm font-medium">⚠️ 알 수 없는 웹타입</span>
|
||||
<div className="border-destructive/30 bg-destructive/5 rounded-md border border-dashed p-3">
|
||||
<div className="text-destructive flex items-center gap-2">
|
||||
<span className="text-xs font-medium">⚠️ 알 수 없는 웹타입</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-500">웹타입 "{webType}"을 렌더링할 수 없습니다.</p>
|
||||
<p className="text-destructive/80 mt-1 text-xs">웹타입 "{webType}"을 렌더링할 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -188,8 +190,8 @@ export const WebTypePreviewRenderer: React.FC<{
|
|||
|
||||
if (!webTypeDefinition) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center">
|
||||
<span className="text-xs text-gray-500">웹타입 없음</span>
|
||||
<div className="border-border bg-muted/30 rounded-md border border-dashed p-2 text-center">
|
||||
<span className="text-muted-foreground text-xs">웹타입 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,18 +88,19 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 삭제 액션 감지 로직 (실제 필드명 사용)
|
||||
const isDeleteAction = () => {
|
||||
const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del'];
|
||||
const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"];
|
||||
return (
|
||||
component.componentConfig?.action?.type === 'delete' ||
|
||||
component.config?.action?.type === 'delete' ||
|
||||
component.webTypeConfig?.actionType === 'delete' ||
|
||||
component.text?.toLowerCase().includes('삭제') ||
|
||||
component.text?.toLowerCase().includes('delete') ||
|
||||
component.label?.toLowerCase().includes('삭제') ||
|
||||
component.label?.toLowerCase().includes('delete') ||
|
||||
deleteKeywords.some(keyword =>
|
||||
component.config?.buttonText?.toLowerCase().includes(keyword) ||
|
||||
component.config?.text?.toLowerCase().includes(keyword)
|
||||
component.componentConfig?.action?.type === "delete" ||
|
||||
component.config?.action?.type === "delete" ||
|
||||
component.webTypeConfig?.actionType === "delete" ||
|
||||
component.text?.toLowerCase().includes("삭제") ||
|
||||
component.text?.toLowerCase().includes("delete") ||
|
||||
component.label?.toLowerCase().includes("삭제") ||
|
||||
component.label?.toLowerCase().includes("delete") ||
|
||||
deleteKeywords.some(
|
||||
(keyword) =>
|
||||
component.config?.buttonText?.toLowerCase().includes(keyword) ||
|
||||
component.config?.text?.toLowerCase().includes(keyword),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -109,9 +110,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
if (isDeleteAction() && !component.style?.labelColor) {
|
||||
// 삭제 액션이고 라벨 색상이 설정되지 않은 경우 빨간색으로 자동 설정
|
||||
if (component.style) {
|
||||
component.style.labelColor = '#ef4444';
|
||||
component.style.labelColor = "#ef4444";
|
||||
} else {
|
||||
component.style = { labelColor: '#ef4444' };
|
||||
component.style = { labelColor: "#ef4444" };
|
||||
}
|
||||
}
|
||||
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
|
||||
|
|
@ -125,20 +126,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||
const getLabelColor = () => {
|
||||
if (isDeleteAction()) {
|
||||
return component.style?.labelColor || '#ef4444'; // 빨간색 기본값 (Tailwind red-500)
|
||||
return component.style?.labelColor || "#ef4444"; // 빨간색 기본값 (Tailwind red-500)
|
||||
}
|
||||
return component.style?.labelColor || '#212121'; // 검은색 기본값 (shadcn/ui primary)
|
||||
return component.style?.labelColor || "#212121"; // 검은색 기본값 (shadcn/ui primary)
|
||||
};
|
||||
|
||||
const buttonColor = getLabelColor();
|
||||
|
||||
|
||||
// 그라데이션용 어두운 색상 계산
|
||||
const getDarkColor = (baseColor: string) => {
|
||||
const hex = baseColor.replace('#', '');
|
||||
const hex = baseColor.replace("#", "");
|
||||
const r = Math.max(0, parseInt(hex.substr(0, 2), 16) - 40);
|
||||
const g = Math.max(0, parseInt(hex.substr(2, 2), 16) - 40);
|
||||
const b = Math.max(0, parseInt(hex.substr(4, 2), 16) - 40);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const buttonDarkColor = getDarkColor(buttonColor);
|
||||
|
|
@ -246,6 +247,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
currentLoadingToastRef.current = undefined;
|
||||
}
|
||||
|
||||
// 실패한 경우 오류 처리
|
||||
if (!success) {
|
||||
console.log("❌ 액션 실패, 오류 토스트 표시");
|
||||
const errorMessage =
|
||||
actionConfig.errorMessage ||
|
||||
(actionConfig.type === "save"
|
||||
? "저장 중 오류가 발생했습니다."
|
||||
: actionConfig.type === "delete"
|
||||
? "삭제 중 오류가 발생했습니다."
|
||||
: actionConfig.type === "submit"
|
||||
? "제출 중 오류가 발생했습니다."
|
||||
: "처리 중 오류가 발생했습니다.");
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 성공한 경우에만 성공 토스트 표시
|
||||
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
|
||||
if (actionConfig.type !== "edit") {
|
||||
const successMessage =
|
||||
|
|
@ -268,24 +286,24 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 저장/수정 성공 시 자동 처리
|
||||
if (actionConfig.type === "save" || actionConfig.type === "edit") {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
// 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에)
|
||||
console.log("🔄 저장/수정 후 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent('refreshTable'));
|
||||
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
|
||||
// 2. 모달 닫기 (약간의 딜레이)
|
||||
setTimeout(() => {
|
||||
// EditModal 내부인지 확인 (isInModal prop 사용)
|
||||
const isInEditModal = (props as any).isInModal;
|
||||
|
||||
|
||||
if (isInEditModal) {
|
||||
console.log("🚪 EditModal 닫기 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent('closeEditModal'));
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
}
|
||||
|
||||
|
||||
// ScreenModal은 항상 닫기
|
||||
console.log("🚪 ScreenModal 닫기 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent('closeSaveModal'));
|
||||
window.dispatchEvent(new CustomEvent("closeSaveModal"));
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
|
@ -301,19 +319,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
console.error("❌ 버튼 액션 실행 오류:", error);
|
||||
|
||||
// 오류 토스트 표시
|
||||
const errorMessage =
|
||||
actionConfig.errorMessage ||
|
||||
(actionConfig.type === "save"
|
||||
? "저장 중 오류가 발생했습니다."
|
||||
: actionConfig.type === "delete"
|
||||
? "삭제 중 오류가 발생했습니다."
|
||||
: actionConfig.type === "submit"
|
||||
? "제출 중 오류가 발생했습니다."
|
||||
: "처리 중 오류가 발생했습니다.");
|
||||
|
||||
console.log("💥 오류 토스트 표시:", errorMessage);
|
||||
toast.error(errorMessage);
|
||||
// 오류 토스트는 buttonActions.ts에서 이미 표시되므로 여기서는 제거
|
||||
// (중복 토스트 방지)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -379,7 +386,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
console.log("⚠️ 액션 실행 조건 불만족:", {
|
||||
isInteractive,
|
||||
hasAction: !!processedConfig.action,
|
||||
"이유": !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
|
||||
이유: !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
|
||||
});
|
||||
// 액션이 설정되지 않은 경우 기본 onClick 실행
|
||||
onClick?.();
|
||||
|
|
@ -472,18 +479,19 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
className="transition-all duration-200"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
maxHeight: "100%",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
background: componentConfig.disabled
|
||||
borderRadius: "0.5rem",
|
||||
background: componentConfig.disabled
|
||||
? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
|
||||
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
|
||||
color: componentConfig.disabled ? "#9ca3af" : "white",
|
||||
fontSize: "14px",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: "600",
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||
outline: "none",
|
||||
|
|
@ -491,13 +499,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 16px",
|
||||
padding: "0 1rem",
|
||||
margin: "0",
|
||||
lineHeight: "1",
|
||||
minHeight: "36px",
|
||||
boxShadow: componentConfig.disabled
|
||||
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
|
||||
: `0 2px 4px 0 ${buttonColor}33`, // 33은 20% 투명도
|
||||
lineHeight: "1.25",
|
||||
minHeight: "2.25rem",
|
||||
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -135,9 +135,9 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
checked={checkedValues.includes(option.value)}
|
||||
onChange={(e) => handleGroupChange(option.value, e.target.checked)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-0 focus:outline-none"
|
||||
className="border-input text-primary h-4 w-4 rounded focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -153,9 +153,9 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
onChange={(e) => handleCheckboxChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="border-input text-primary focus:ring-ring h-4 w-4 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
<span className="text-sm">{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
|
|||
name: "체크박스",
|
||||
nameEng: "CheckboxBasic Component",
|
||||
description: "체크 상태 선택을 위한 체크박스 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "checkbox",
|
||||
component: CheckboxBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -5,29 +5,29 @@
|
|||
export const INPUT_CLASSES = {
|
||||
// 기본 input 스타일
|
||||
base: `
|
||||
w-full h-full px-3 py-2 text-sm
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-gray-900
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
|
||||
placeholder:text-gray-400
|
||||
w-full h-9 px-3 py-2 text-sm
|
||||
border border-input rounded-md
|
||||
bg-background text-foreground
|
||||
transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
placeholder:text-muted-foreground
|
||||
max-w-full box-border
|
||||
`,
|
||||
|
||||
// 선택된 상태
|
||||
selected: `
|
||||
border-blue-500 ring-2 ring-blue-100
|
||||
ring-2 ring-primary/20
|
||||
`,
|
||||
|
||||
// 라벨 스타일
|
||||
label: `
|
||||
absolute -top-6 left-0 text-sm font-medium text-slate-600
|
||||
absolute -top-6 left-0 text-xs font-medium text-foreground
|
||||
`,
|
||||
|
||||
// 필수 표시
|
||||
required: `
|
||||
text-red-500
|
||||
ml-0.5 text-destructive
|
||||
`,
|
||||
|
||||
// 컨테이너
|
||||
|
|
@ -37,24 +37,24 @@ export const INPUT_CLASSES = {
|
|||
|
||||
// textarea
|
||||
textarea: `
|
||||
w-full h-full px-3 py-2 text-sm
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-gray-900
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
|
||||
min-h-[80px] w-full px-3 py-2 text-sm
|
||||
border border-input rounded-md
|
||||
bg-background text-foreground
|
||||
transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
resize-none
|
||||
max-w-full box-border
|
||||
`,
|
||||
|
||||
// select
|
||||
select: `
|
||||
w-full h-full px-3 py-2 text-sm
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-gray-900
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
|
||||
h-9 w-full px-3 py-2 text-sm
|
||||
border border-input rounded-md
|
||||
bg-background text-foreground
|
||||
transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
cursor-pointer
|
||||
max-w-full box-border
|
||||
`,
|
||||
|
|
@ -66,37 +66,37 @@ export const INPUT_CLASSES = {
|
|||
|
||||
// 구분자 (@ , ~ 등)
|
||||
separator: `
|
||||
text-base font-medium text-gray-500
|
||||
text-sm font-medium text-muted-foreground
|
||||
`,
|
||||
|
||||
// Currency 통화 기호
|
||||
currencySymbol: `
|
||||
text-base font-semibold text-green-600 pl-2
|
||||
text-sm font-semibold text-green-600 pl-2
|
||||
`,
|
||||
|
||||
// Currency input
|
||||
currencyInput: `
|
||||
flex-1 h-full px-3 py-2 text-base font-semibold text-right
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-green-600
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400
|
||||
flex-1 h-9 px-3 py-2 text-sm font-semibold text-right
|
||||
border border-input rounded-md
|
||||
bg-background text-green-600
|
||||
transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
`,
|
||||
|
||||
// Percentage 퍼센트 기호
|
||||
percentageSymbol: `
|
||||
text-base font-semibold text-blue-600 pr-2
|
||||
text-sm font-semibold text-blue-600 pr-2
|
||||
`,
|
||||
|
||||
// Percentage input
|
||||
percentageInput: `
|
||||
flex-1 h-full px-3 py-2 text-base font-semibold text-right
|
||||
border border-gray-300 rounded-md
|
||||
bg-white text-blue-600
|
||||
outline-none transition-all duration-200
|
||||
focus:border-orange-500 focus:ring-2 focus:ring-orange-100
|
||||
disabled:bg-gray-100 disabled:text-gray-400
|
||||
flex-1 h-9 px-3 py-2 text-sm font-semibold text-right
|
||||
border border-input rounded-md
|
||||
bg-background text-blue-600
|
||||
transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,7 +37,9 @@ import "./divider-line/DividerLineRenderer";
|
|||
import "./accordion-basic/AccordionBasicRenderer";
|
||||
import "./table-list/TableListRenderer";
|
||||
import "./card-display/CardDisplayRenderer";
|
||||
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||
import "./map/MapRenderer";
|
||||
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -138,9 +138,9 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
onChange={() => handleRadioChange(option.value)}
|
||||
disabled={componentConfig.disabled || isDesignMode}
|
||||
required={componentConfig.required || false}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-0"
|
||||
className="border-input text-primary h-4 w-4 focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const RadioBasicDefinition = createComponentDefinition({
|
|||
name: "라디오 버튼",
|
||||
nameEng: "RadioBasic Component",
|
||||
description: "단일 옵션 선택을 위한 라디오 버튼 그룹 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "radio",
|
||||
component: RadioBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
|
||||
import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
|
||||
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
|
||||
|
||||
/**
|
||||
* Repeater Field Group 컴포넌트
|
||||
*/
|
||||
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
|
||||
const { component, value, onChange, readonly, disabled } = props;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 값이 JSON 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = [];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
parsedValue = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={(newValue) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
}}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Repeater Field Group 렌더러
|
||||
* 여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 컴포넌트
|
||||
*/
|
||||
export class RepeaterFieldGroupRenderer extends AutoRegisteringComponentRenderer {
|
||||
/**
|
||||
* 컴포넌트 정의
|
||||
*/
|
||||
static componentDefinition: ComponentDefinition = {
|
||||
id: "repeater-field-group",
|
||||
name: "반복 필드 그룹",
|
||||
nameEng: "Repeater Field Group",
|
||||
description: "여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 반복 가능한 필드 그룹",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "array", // 배열 데이터를 다룸
|
||||
icon: Layers,
|
||||
component: RepeaterFieldGroupRenderer,
|
||||
configPanel: RepeaterConfigPanel,
|
||||
defaultSize: {
|
||||
width: 600,
|
||||
height: 200, // 기본 높이 조정
|
||||
},
|
||||
defaultConfig: {
|
||||
fields: [], // 빈 배열로 시작 - 사용자가 직접 필드 추가
|
||||
minItems: 1, // 기본 1개 항목
|
||||
maxItems: 20,
|
||||
addButtonText: "항목 추가",
|
||||
allowReorder: true,
|
||||
showIndex: true,
|
||||
collapsible: false,
|
||||
layout: "grid",
|
||||
showDivider: true,
|
||||
emptyMessage: "필드를 먼저 정의하세요.",
|
||||
},
|
||||
tags: ["repeater", "fieldgroup", "dynamic", "multi", "form", "array", "fields"],
|
||||
author: "System",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 렌더링
|
||||
*/
|
||||
render(): React.ReactElement {
|
||||
return <RepeaterFieldGroupComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
RepeaterFieldGroupRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
RepeaterFieldGroupRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -323,9 +323,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
checked={selectedValue === option.value}
|
||||
onChange={() => handleOptionSelect(option.value, option.label)}
|
||||
disabled={isDesignMode}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="border-input text-primary focus:ring-ring h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option.label}</span>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const SelectBasicDefinition = createComponentDefinition({
|
|||
name: "선택상자",
|
||||
nameEng: "SelectBasic Component",
|
||||
description: "옵션 선택을 위한 드롭다운 선택상자 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "select",
|
||||
component: SelectBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const SliderBasicDefinition = createComponentDefinition({
|
|||
name: "슬라이더",
|
||||
nameEng: "SliderBasic Component",
|
||||
description: "범위 값 선택을 위한 슬라이더 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "number",
|
||||
component: SliderBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
# SplitPanelLayout 컴포넌트
|
||||
|
||||
마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트입니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 🔄 **마스터-디테일 패턴**: 좌측에서 항목 선택 시 우측에 상세 정보 표시
|
||||
- 📏 **크기 조절 가능**: 드래그하여 좌우 패널 크기 조정
|
||||
- 🔍 **검색 기능**: 각 패널에 독립적인 검색 기능
|
||||
- 🔗 **관계 설정**: JOIN, DETAIL, CUSTOM 관계 타입 지원
|
||||
- ⚙️ **유연한 설정**: 다양한 옵션으로 커스터마이징 가능
|
||||
|
||||
## 사용 사례
|
||||
|
||||
### 1. 코드 관리
|
||||
|
||||
- 좌측: 코드 카테고리 목록
|
||||
- 우측: 선택된 카테고리의 코드 목록
|
||||
|
||||
### 2. 테이블 조인 설정
|
||||
|
||||
- 좌측: 기본 테이블 목록
|
||||
- 우측: 선택된 테이블의 조인 조건 설정
|
||||
|
||||
### 3. 메뉴 관리
|
||||
|
||||
- 좌측: 메뉴 트리 구조
|
||||
- 우측: 선택된 메뉴의 상세 설정
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### 좌측 패널 (leftPanel)
|
||||
|
||||
- `title`: 패널 제목
|
||||
- `tableName`: 데이터베이스 테이블명
|
||||
- `showSearch`: 검색 기능 표시 여부
|
||||
- `showAdd`: 추가 버튼 표시 여부
|
||||
|
||||
### 우측 패널 (rightPanel)
|
||||
|
||||
- `title`: 패널 제목
|
||||
- `tableName`: 데이터베이스 테이블명
|
||||
- `showSearch`: 검색 기능 표시 여부
|
||||
- `showAdd`: 추가 버튼 표시 여부
|
||||
- `relation`: 좌측 항목과의 관계 설정
|
||||
- `type`: "join" | "detail" | "custom"
|
||||
- `foreignKey`: 외래키 컬럼명
|
||||
|
||||
### 레이아웃 설정
|
||||
|
||||
- `splitRatio`: 좌측 패널 너비 비율 (0-100, 기본 30)
|
||||
- `resizable`: 크기 조절 가능 여부 (기본 true)
|
||||
- `minLeftWidth`: 좌측 최소 너비 (기본 200px)
|
||||
- `minRightWidth`: 우측 최소 너비 (기본 300px)
|
||||
- `autoLoad`: 자동 데이터 로드 (기본 true)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
const config: SplitPanelLayoutConfig = {
|
||||
leftPanel: {
|
||||
title: "코드 카테고리",
|
||||
tableName: "code_category",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "코드 목록",
|
||||
tableName: "code_info",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "category_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
resizable: true,
|
||||
};
|
||||
```
|
||||
|
|
@ -0,0 +1,616 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
}
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
*/
|
||||
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isPreview = false,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
||||
|
||||
// 기본 설정값
|
||||
const splitRatio = componentConfig.splitRatio || 30;
|
||||
const resizable = componentConfig.resizable ?? true;
|
||||
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
||||
const minRightWidth = componentConfig.minRightWidth || 300;
|
||||
|
||||
// 데이터 상태
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
||||
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||
const { toast } = useToast();
|
||||
|
||||
// 리사이저 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 컴포넌트 스타일
|
||||
const componentStyle: React.CSSProperties = isPreview
|
||||
? {
|
||||
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
|
||||
position: "relative",
|
||||
// width 제거 - 그리드 컬럼이 결정
|
||||
height: `${component.style?.height || 600}px`,
|
||||
border: "1px solid #e5e7eb",
|
||||
}
|
||||
: {
|
||||
// 디자이너 모드: position absolute
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: `${component.style?.width || 1000}px`,
|
||||
height: `${component.style?.height || 600}px`,
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||
};
|
||||
|
||||
// 좌측 데이터 로드
|
||||
const loadLeftData = useCallback(async () => {
|
||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||
if (!leftTableName || isDesignMode) return;
|
||||
|
||||
setIsLoadingLeft(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData(leftTableName, {
|
||||
page: 1,
|
||||
size: 100,
|
||||
// searchTerm 제거 - 클라이언트 사이드에서 필터링
|
||||
});
|
||||
setLeftData(result.data);
|
||||
} catch (error) {
|
||||
console.error("좌측 데이터 로드 실패:", error);
|
||||
toast({
|
||||
title: "데이터 로드 실패",
|
||||
description: "좌측 패널 데이터를 불러올 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingLeft(false);
|
||||
}
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast]);
|
||||
|
||||
// 우측 데이터 로드
|
||||
const loadRightData = useCallback(
|
||||
async (leftItem: any) => {
|
||||
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||
|
||||
if (!rightTableName || isDesignMode) return;
|
||||
|
||||
setIsLoadingRight(true);
|
||||
try {
|
||||
if (relationshipType === "detail") {
|
||||
// 상세 모드: 동일 테이블의 상세 정보
|
||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
||||
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
||||
setRightData(detail);
|
||||
} else if (relationshipType === "join") {
|
||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
||||
const leftTable = componentConfig.leftPanel?.tableName;
|
||||
|
||||
if (leftColumn && rightColumn && leftTable) {
|
||||
const leftValue = leftItem[leftColumn];
|
||||
const joinedData = await dataApi.getJoinedData(
|
||||
leftTable,
|
||||
rightTableName,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
);
|
||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("우측 데이터 로드 실패:", error);
|
||||
toast({
|
||||
title: "데이터 로드 실패",
|
||||
description: "우측 패널 데이터를 불러올 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingRight(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
componentConfig.rightPanel?.tableName,
|
||||
componentConfig.rightPanel?.relation,
|
||||
componentConfig.leftPanel?.tableName,
|
||||
isDesignMode,
|
||||
toast,
|
||||
],
|
||||
);
|
||||
|
||||
// 좌측 항목 선택 핸들러
|
||||
const handleLeftItemSelect = useCallback(
|
||||
(item: any) => {
|
||||
setSelectedLeftItem(item);
|
||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||
loadRightData(item);
|
||||
},
|
||||
[loadRightData],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
const toggleRightItemExpansion = useCallback((itemId: string | number) => {
|
||||
setExpandedRightItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 컬럼명을 라벨로 변환하는 함수
|
||||
const getColumnLabel = useCallback(
|
||||
(columnName: string) => {
|
||||
const column = rightTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName);
|
||||
return column?.columnLabel || column?.column_label || column?.displayName || columnName;
|
||||
},
|
||||
[rightTableColumns],
|
||||
);
|
||||
|
||||
// 우측 테이블 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadRightTableColumns = async () => {
|
||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||
if (!rightTableName || isDesignMode) return;
|
||||
|
||||
try {
|
||||
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
||||
setRightTableColumns(columnsResponse || []);
|
||||
} catch (error) {
|
||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadRightTableColumns();
|
||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||
loadLeftData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDesignMode, componentConfig.autoLoad]);
|
||||
|
||||
// 리사이저 드래그 핸들러
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!resizable) return;
|
||||
setIsDragging(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
const relativeX = e.clientX - containerRect.left;
|
||||
const newLeftWidth = (relativeX / containerWidth) * 100;
|
||||
|
||||
// 최소/최대 너비 제한 (20% ~ 80%)
|
||||
if (newLeftWidth >= 20 && newLeftWidth <= 80) {
|
||||
setLeftWidth(newLeftWidth);
|
||||
}
|
||||
},
|
||||
[isDragging],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isDragging) {
|
||||
// 드래그 중에는 텍스트 선택 방지
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={
|
||||
isPreview
|
||||
? {
|
||||
position: "relative",
|
||||
height: `${component.style?.height || 600}px`,
|
||||
border: "1px solid #e5e7eb",
|
||||
}
|
||||
: componentStyle
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (isDesignMode) {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}
|
||||
}}
|
||||
className={`flex overflow-hidden rounded-lg bg-white shadow-sm ${isPreview ? "w-full" : ""}`}
|
||||
>
|
||||
{/* 좌측 패널 */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px` }}
|
||||
className="border-border flex flex-shrink-0 flex-col border-r"
|
||||
>
|
||||
<Card className="flex h-full flex-col border-0 shadow-none">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||
</CardTitle>
|
||||
{componentConfig.leftPanel?.showAdd && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{componentConfig.leftPanel?.showSearch && (
|
||||
<div className="relative mt-2">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={leftSearchQuery}
|
||||
onChange={(e) => setLeftSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-2">
|
||||
{/* 좌측 데이터 목록 */}
|
||||
<div className="space-y-1">
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: 샘플 데이터
|
||||
<>
|
||||
<div
|
||||
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
|
||||
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
|
||||
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">항목 1</div>
|
||||
<div className="text-muted-foreground text-xs">설명 텍스트</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
|
||||
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
|
||||
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">항목 2</div>
|
||||
<div className="text-muted-foreground text-xs">설명 텍스트</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
|
||||
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
|
||||
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">항목 3</div>
|
||||
<div className="text-muted-foreground text-xs">설명 텍스트</div>
|
||||
</div>
|
||||
</>
|
||||
) : isLoadingLeft ? (
|
||||
// 로딩 중
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
// 검색 필터링 (클라이언트 사이드)
|
||||
const filteredLeftData = leftSearchQuery
|
||||
? leftData.filter((item) => {
|
||||
const searchLower = leftSearchQuery.toLowerCase();
|
||||
return Object.entries(item).some(([key, value]) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
})
|
||||
: leftData;
|
||||
|
||||
return filteredLeftData.length > 0 ? (
|
||||
// 실제 데이터 표시
|
||||
filteredLeftData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index;
|
||||
const isSelected =
|
||||
selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item);
|
||||
// 첫 번째 2-3개 필드를 표시
|
||||
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
||||
const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`;
|
||||
const displaySubtitle = keys[1] ? item[keys[1]] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
isSelected ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate font-medium">{displayTitle}</div>
|
||||
{displaySubtitle && <div className="truncate text-xs text-gray-500">{displaySubtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 검색 결과 없음
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
{leftSearchQuery ? (
|
||||
<>
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-400">다른 검색어를 입력해보세요.</p>
|
||||
</>
|
||||
) : (
|
||||
"데이터가 없습니다."
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{resizable && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="group flex w-1 cursor-col-resize items-center justify-center bg-gray-200 transition-colors hover:bg-blue-400"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-gray-400 group-hover:text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div
|
||||
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px` }}
|
||||
className="flex flex-shrink-0 flex-col"
|
||||
>
|
||||
<Card className="flex h-full flex-col border-0 shadow-none">
|
||||
<CardHeader className="border-b pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
||||
</CardTitle>
|
||||
{componentConfig.rightPanel?.showAdd && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{componentConfig.rightPanel?.showSearch && (
|
||||
<div className="relative mt-2">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={rightSearchQuery}
|
||||
onChange={(e) => setRightSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{/* 우측 데이터 */}
|
||||
{isLoadingRight ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
// 실제 데이터 표시
|
||||
Array.isArray(rightData) ? (
|
||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||
(() => {
|
||||
// 검색 필터링
|
||||
const filteredData = rightSearchQuery
|
||||
? rightData.filter((item) => {
|
||||
const searchLower = rightSearchQuery.toLowerCase();
|
||||
return Object.entries(item).some(([key, value]) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
})
|
||||
: rightData;
|
||||
|
||||
return filteredData.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-2 text-xs text-gray-500">
|
||||
{filteredData.length}개의 관련 데이터
|
||||
{rightSearchQuery && filteredData.length !== rightData.length && (
|
||||
<span className="ml-1 text-blue-600">(전체 {rightData.length}개 중)</span>
|
||||
)}
|
||||
</div>
|
||||
{filteredData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || index;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
const firstValues = Object.entries(item)
|
||||
.filter(([key]) => !key.toLowerCase().includes("id"))
|
||||
.slice(0, 3);
|
||||
const allValues = Object.entries(item).filter(
|
||||
([key, value]) => value !== null && value !== undefined && value !== "",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
{/* 요약 정보 (클릭 가능) */}
|
||||
<div
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
className="cursor-pointer p-3 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{firstValues.map(([key, value], idx) => (
|
||||
<div key={key} className="mb-1 last:mb-0">
|
||||
<div className="text-xs font-medium text-gray-500">{getColumnLabel(key)}</div>
|
||||
<div className="truncate text-sm text-gray-900" title={String(value || "-")}>
|
||||
{String(value || "-")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-start pt-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 (확장 시 표시) */}
|
||||
{isExpanded && (
|
||||
<div className="bg-muted/50 border-t px-3 py-2">
|
||||
<div className="mb-2 text-xs font-semibold">전체 상세 정보</div>
|
||||
<div className="bg-card overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{allValues.map(([key, value]) => (
|
||||
<tr key={key} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium whitespace-nowrap text-gray-600">
|
||||
{getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="px-3 py-2 break-all text-gray-900">{String(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
{rightSearchQuery ? (
|
||||
<>
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-400">다른 검색어를 입력해보세요.</p>
|
||||
</>
|
||||
) : (
|
||||
"관련 데이터가 없습니다."
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
// 상세 모드: 단일 객체를 상세 정보로 표시
|
||||
<div className="space-y-2">
|
||||
{Object.entries(rightData).map(([key, value]) => {
|
||||
// null, undefined, 빈 문자열 제외
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-sm">{String(value)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : selectedLeftItem && isDesignMode ? (
|
||||
// 디자인 모드: 샘플 데이터
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-2 font-medium">{selectedLeftItem.name} 상세 정보</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">항목 1:</span>
|
||||
<span className="font-medium">값 1</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">항목 2:</span>
|
||||
<span className="font-medium">값 2</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">항목 3:</span>
|
||||
<span className="font-medium">값 3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 선택 없음
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 래퍼 컴포넌트
|
||||
*/
|
||||
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
|
||||
return <SplitPanelLayoutComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
interface SplitPanelLayoutConfigPanelProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
|
||||
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
|
||||
}
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 설정 패널
|
||||
*/
|
||||
export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
tables = [], // 기본값 빈 배열 (현재 화면 테이블만)
|
||||
screenTableName, // 현재 화면의 테이블명
|
||||
}) => {
|
||||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
|
||||
const [rightColumnOpen, setRightColumnOpen] = useState(false);
|
||||
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
|
||||
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||
const [allTables, setAllTables] = useState<any[]>([]); // 조인 모드용 전체 테이블 목록
|
||||
|
||||
// 관계 타입
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
|
||||
// 조인 모드일 때만 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (relationshipType === "join") {
|
||||
const loadAllTables = async () => {
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
console.log("✅ 분할패널 조인 모드: 전체 테이블 목록 로드", response.data.length, "개");
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 전체 테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
} else {
|
||||
// 상세 모드일 때는 기본 테이블만 사용
|
||||
setAllTables([]);
|
||||
}
|
||||
}, [relationshipType]);
|
||||
|
||||
// screenTableName이 변경되면 좌측 패널 테이블을 항상 화면 테이블로 설정
|
||||
useEffect(() => {
|
||||
if (screenTableName) {
|
||||
// 좌측 패널은 항상 현재 화면의 테이블 사용
|
||||
if (config.leftPanel?.tableName !== screenTableName) {
|
||||
updateLeftPanel({ tableName: screenTableName });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenTableName]);
|
||||
|
||||
// 테이블 컬럼 로드 함수
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (loadedTableColumns[tableName] || loadingColumns[tableName]) {
|
||||
return; // 이미 로드되었거나 로딩 중
|
||||
}
|
||||
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
||||
|
||||
try {
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
console.log(`📊 테이블 ${tableName} 컬럼 응답:`, columnsResponse);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
|
||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] }));
|
||||
} finally {
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (config.leftPanel?.tableName) {
|
||||
loadTableColumns(config.leftPanel.tableName);
|
||||
}
|
||||
}, [config.leftPanel?.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.rightPanel?.tableName) {
|
||||
loadTableColumns(config.rightPanel.tableName);
|
||||
}
|
||||
}, [config.rightPanel?.tableName]);
|
||||
|
||||
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
|
||||
console.log(" - config:", config);
|
||||
console.log(" - tables:", tables);
|
||||
console.log(" - tablesCount:", tables.length);
|
||||
console.log(" - screenTableName:", screenTableName);
|
||||
console.log(" - leftTable:", config.leftPanel?.tableName);
|
||||
console.log(" - rightTable:", config.rightPanel?.tableName);
|
||||
|
||||
const updateConfig = (updates: Partial<SplitPanelLayoutConfig>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
console.log("🔄 Config 업데이트:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
const updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
leftPanel: { ...config.leftPanel, ...updates },
|
||||
};
|
||||
console.log("🔄 Left Panel 업데이트:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
const updateRightPanel = (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
rightPanel: { ...config.rightPanel, ...updates },
|
||||
};
|
||||
console.log("🔄 Right Panel 업데이트:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 좌측 테이블 컬럼 (로드된 컬럼 사용)
|
||||
const leftTableColumns = useMemo(() => {
|
||||
const tableName = config.leftPanel?.tableName || screenTableName;
|
||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
||||
}, [loadedTableColumns, config.leftPanel?.tableName, screenTableName]);
|
||||
|
||||
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
||||
const rightTableColumns = useMemo(() => {
|
||||
const tableName = config.rightPanel?.tableName;
|
||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
||||
}, [loadedTableColumns, config.rightPanel?.tableName]);
|
||||
|
||||
// 테이블 데이터 로딩 상태 확인
|
||||
if (!tables || tables.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">테이블 데이터를 불러올 수 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
화면에 테이블이 연결되지 않았거나 테이블 목록이 로드되지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 조인 모드에서 우측 테이블 선택 시 사용할 테이블 목록
|
||||
const availableRightTables = relationshipType === "join" ? allTables : tables;
|
||||
|
||||
console.log("📊 분할패널 테이블 목록 상태:");
|
||||
console.log(" - relationshipType:", relationshipType);
|
||||
console.log(" - allTables:", allTables.length, "개");
|
||||
console.log(" - availableRightTables:", availableRightTables.length, "개");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
||||
<Select
|
||||
value={relationshipType}
|
||||
onValueChange={(value: "join" | "detail") => {
|
||||
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
|
||||
if (value === "detail" && screenTableName) {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, type: value },
|
||||
tableName: screenTableName,
|
||||
});
|
||||
} else {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, type: value },
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="관계 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="detail">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">상세 (DETAIL)</span>
|
||||
<span className="text-xs text-gray-500">좌측 목록 → 우측 상세 정보 (동일 테이블)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="join">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">조인 (JOIN)</span>
|
||||
<span className="text-xs text-gray-500">좌측 테이블 → 우측 관련 테이블 (다른 테이블)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 좌측 패널 설정 (마스터) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.title || ""}
|
||||
onChange={(e) => updateLeftPanel({ title: e.target.value })}
|
||||
placeholder="좌측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (현재 화면 고정)</Label>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">좌측 패널은 현재 화면의 테이블 데이터를 표시합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>검색 기능</Label>
|
||||
<Switch
|
||||
checked={config.leftPanel?.showSearch ?? true}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showSearch: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>추가 버튼</Label>
|
||||
<Switch
|
||||
checked={config.leftPanel?.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.title || ""}
|
||||
onChange={(e) => updateRightPanel({ title: e.target.value })}
|
||||
placeholder="우측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
||||
{relationshipType === "detail" ? (
|
||||
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (좌측과 동일)</Label>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">상세 모드에서는 좌측과 동일한 테이블을 사용합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 조인 모드: 전체 테이블에서 선택 가능
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택 (전체 테이블)</Label>
|
||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightTableOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{config.rightPanel?.tableName || "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{availableRightTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(value) => {
|
||||
updateRightPanel({ tableName: value });
|
||||
setRightTableOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
||||
{relationshipType !== "detail" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-sm font-semibold">컬럼 매핑 (외래키 관계)</Label>
|
||||
<p className="text-xs text-gray-600">좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">좌측 컬럼</Label>
|
||||
<Popover open={leftColumnOpen} onOpenChange={setLeftColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={leftColumnOpen}
|
||||
className="w-full justify-between"
|
||||
disabled={!config.leftPanel?.tableName}
|
||||
>
|
||||
{config.rightPanel?.relation?.leftColumn || "좌측 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." />
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{leftTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, leftColumn: value },
|
||||
});
|
||||
setLeftColumnOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.relation?.leftColumn === column.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{column.columnName}
|
||||
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">우측 컬럼 (외래키)</Label>
|
||||
<Popover open={rightColumnOpen} onOpenChange={setRightColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightColumnOpen}
|
||||
className="w-full justify-between"
|
||||
disabled={!config.rightPanel?.tableName}
|
||||
>
|
||||
{config.rightPanel?.relation?.foreignKey || "우측 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." />
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{rightTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, foreignKey: value },
|
||||
});
|
||||
setRightColumnOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.relation?.foreignKey === column.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{column.columnName}
|
||||
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>검색 기능</Label>
|
||||
<Switch
|
||||
checked={config.rightPanel?.showSearch ?? true}
|
||||
onCheckedChange={(checked) => updateRightPanel({ showSearch: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>추가 버튼</Label>
|
||||
<Switch
|
||||
checked={config.rightPanel?.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
<Slider
|
||||
value={[config.splitRatio || 30]}
|
||||
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
|
||||
min={20}
|
||||
max={80}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>크기 조절 가능</Label>
|
||||
<Switch
|
||||
checked={config.resizable ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>자동 데이터 로드</Label>
|
||||
<Switch
|
||||
checked={config.autoLoad ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { SplitPanelLayoutDefinition } from "./index";
|
||||
import { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SplitPanelLayoutRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = SplitPanelLayoutDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SplitPanelLayoutComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// 좌측 패널 데이터 로드
|
||||
protected async loadLeftPanelData() {
|
||||
// 좌측 패널 데이터 로드 로직
|
||||
}
|
||||
|
||||
// 우측 패널 데이터 로드 (선택된 항목 기반)
|
||||
protected async loadRightPanelData(selectedItem: any) {
|
||||
// 우측 패널 데이터 로드 로직
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SplitPanelLayoutRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SplitPanelLayoutRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* SplitPanelLayout 컴포넌트 설정
|
||||
*/
|
||||
|
||||
export const splitPanelLayoutConfig = {
|
||||
// 기본 스타일
|
||||
defaultStyle: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
|
||||
// 프리셋 설정들
|
||||
presets: {
|
||||
codeManagement: {
|
||||
name: "코드 관리",
|
||||
leftPanel: {
|
||||
title: "코드 카테고리",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "코드 목록",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "category_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
},
|
||||
tableJoin: {
|
||||
name: "테이블 조인",
|
||||
leftPanel: {
|
||||
title: "기본 테이블",
|
||||
showSearch: true,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "조인 조건",
|
||||
showSearch: false,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "join",
|
||||
},
|
||||
},
|
||||
splitRatio: 35,
|
||||
},
|
||||
menuSettings: {
|
||||
name: "메뉴 설정",
|
||||
leftPanel: {
|
||||
title: "메뉴 트리",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "메뉴 상세",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "menu_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 25,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SplitPanelLayoutWrapper } from "./SplitPanelLayoutComponent";
|
||||
import { SplitPanelLayoutConfigPanel } from "./SplitPanelLayoutConfigPanel";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트 정의
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
*/
|
||||
export const SplitPanelLayoutDefinition = createComponentDefinition({
|
||||
id: "split-panel-layout",
|
||||
name: "분할 패널",
|
||||
nameEng: "SplitPanelLayout Component",
|
||||
description: "마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: SplitPanelLayoutWrapper,
|
||||
defaultConfig: {
|
||||
leftPanel: {
|
||||
title: "마스터",
|
||||
showSearch: true,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "디테일",
|
||||
showSearch: true,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "parent_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
resizable: true,
|
||||
minLeftWidth: 200,
|
||||
minRightWidth: 300,
|
||||
autoLoad: true,
|
||||
syncSelection: true,
|
||||
} as SplitPanelLayoutConfig,
|
||||
defaultSize: { width: 800, height: 600 },
|
||||
configPanel: SplitPanelLayoutConfigPanel,
|
||||
icon: "PanelLeftRight",
|
||||
tags: ["분할", "마스터", "디테일", "레이아웃"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/split-panel-layout",
|
||||
});
|
||||
|
||||
// 컴포넌트는 SplitPanelLayoutRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { SplitPanelLayoutConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* SplitPanelLayout 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
export interface SplitPanelLayoutConfig {
|
||||
// 좌측 패널 설정
|
||||
leftPanel: {
|
||||
title: string;
|
||||
tableName?: string; // 데이터베이스 테이블명
|
||||
dataSource?: string; // API 엔드포인트
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// 우측 패널 설정
|
||||
rightPanel: {
|
||||
title: string;
|
||||
tableName?: string;
|
||||
dataSource?: string;
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
}>;
|
||||
|
||||
// 좌측 선택 항목과의 관계 설정
|
||||
relation?: {
|
||||
type: "join" | "detail"; // 관계 타입
|
||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼
|
||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||
};
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
splitRatio?: number; // 좌우 비율 (0-100, 기본 30)
|
||||
resizable?: boolean; // 크기 조절 가능 여부
|
||||
minLeftWidth?: number; // 좌측 최소 너비 (px)
|
||||
minRightWidth?: number; // 우측 최소 너비 (px)
|
||||
|
||||
// 동작 설정
|
||||
autoLoad?: boolean; // 자동 데이터 로드
|
||||
syncSelection?: boolean; // 선택 항목 동기화
|
||||
}
|
||||
|
|
@ -155,14 +155,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onSelectedRowsChange,
|
||||
onConfigChange,
|
||||
refreshKey,
|
||||
tableName, // 화면의 기본 테이블명 (screenInfo에서 전달)
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const tableConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
...componentConfig,
|
||||
// selectedTable이 없으면 화면의 기본 테이블 사용
|
||||
selectedTable:
|
||||
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName,
|
||||
} as TableListConfig;
|
||||
|
||||
console.log("🔍 TableListComponent 초기화:", {
|
||||
componentConfigSelectedTable: componentConfig?.selectedTable,
|
||||
componentConfigSelectedTable2: component.config?.selectedTable,
|
||||
configSelectedTable: config?.selectedTable,
|
||||
screenTableName: tableName,
|
||||
finalSelectedTable: tableConfig.selectedTable,
|
||||
});
|
||||
|
||||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||
const buttonColor = component.style?.labelColor || "#212121"; // 기본 파란색
|
||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||
|
|
@ -424,20 +436,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 디바운싱된 테이블 데이터 가져오기
|
||||
const fetchTableDataDebounced = useCallback(
|
||||
debouncedApiCall(
|
||||
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
|
||||
async () => {
|
||||
return fetchTableDataInternal();
|
||||
},
|
||||
200, // 200ms 디바운스
|
||||
),
|
||||
[tableConfig.selectedTable, currentPage, localPageSize, searchTerm, sortColumn, sortDirection, searchValues],
|
||||
);
|
||||
|
||||
// 실제 테이블 데이터 가져오기 함수
|
||||
const fetchTableDataInternal = async () => {
|
||||
const fetchTableDataInternal = useCallback(async () => {
|
||||
if (!tableConfig.selectedTable) {
|
||||
setData([]);
|
||||
return;
|
||||
|
|
@ -448,81 +448,54 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
try {
|
||||
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
|
||||
console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable);
|
||||
|
||||
// Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들)
|
||||
const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || [];
|
||||
|
||||
// 🎯 조인 탭에서 추가한 컬럼들도 포함 (실제로 존재하는 컬럼만)
|
||||
const joinTabColumns =
|
||||
tableConfig.columns?.filter(
|
||||
(col) =>
|
||||
!col.isEntityJoin &&
|
||||
col.columnName.includes("_") &&
|
||||
(col.columnName.includes("dept_code_") ||
|
||||
col.columnName.includes("_dept_code") ||
|
||||
col.columnName.includes("_company_") ||
|
||||
col.columnName.includes("_user_")), // 조인 탭에서 추가한 컬럼 패턴들
|
||||
) || [];
|
||||
// 🎯 조인 탭에서 추가한 컬럼들 추출 (additionalJoinInfo가 있는 컬럼들)
|
||||
const manualJoinColumns =
|
||||
tableConfig.columns?.filter((col) => {
|
||||
return col.additionalJoinInfo !== undefined;
|
||||
}) || [];
|
||||
|
||||
console.log(
|
||||
"🔍 조인 탭 컬럼들:",
|
||||
joinTabColumns.map((c) => c.columnName),
|
||||
"🔗 수동 조인 컬럼 감지:",
|
||||
manualJoinColumns.map((c) => ({
|
||||
columnName: c.columnName,
|
||||
additionalJoinInfo: c.additionalJoinInfo,
|
||||
})),
|
||||
);
|
||||
|
||||
const additionalJoinColumns = [
|
||||
...entityJoinColumns.map((col) => ({
|
||||
// 🎯 추가 조인 컬럼 정보 구성
|
||||
const additionalJoinColumns: Array<{
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
joinAlias: string;
|
||||
referenceTable?: string;
|
||||
}> = [];
|
||||
|
||||
// Entity 조인 컬럼들
|
||||
entityJoinColumns.forEach((col) => {
|
||||
additionalJoinColumns.push({
|
||||
sourceTable: col.entityJoinInfo!.sourceTable,
|
||||
sourceColumn: col.entityJoinInfo!.sourceColumn,
|
||||
joinAlias: col.entityJoinInfo!.joinAlias,
|
||||
})),
|
||||
// 🎯 조인 탭에서 추가한 컬럼들도 추가 (실제로 존재하는 컬럼만)
|
||||
...joinTabColumns
|
||||
.filter((col) => {
|
||||
// 실제 API 응답에 존재하는 컬럼만 필터링
|
||||
const validJoinColumns = ["dept_code_name", "dept_name"];
|
||||
const isValid = validJoinColumns.includes(col.columnName);
|
||||
if (!isValid) {
|
||||
console.log(`🔍 조인 탭 컬럼 제외: ${col.columnName} (유효하지 않음)`);
|
||||
}
|
||||
return isValid;
|
||||
})
|
||||
.map((col) => {
|
||||
// 실제 존재하는 조인 컬럼만 처리
|
||||
let sourceTable = tableConfig.selectedTable;
|
||||
let sourceColumn = col.columnName;
|
||||
});
|
||||
});
|
||||
|
||||
if (col.columnName === "dept_code_name" || col.columnName === "dept_name") {
|
||||
sourceTable = "dept_info";
|
||||
sourceColumn = "dept_code";
|
||||
}
|
||||
|
||||
console.log(`🔍 조인 탭 컬럼 처리: ${col.columnName} -> ${sourceTable}.${sourceColumn}`);
|
||||
|
||||
return {
|
||||
sourceTable: sourceTable || tableConfig.selectedTable || "",
|
||||
sourceColumn: sourceColumn,
|
||||
joinAlias: col.columnName,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
// 🎯 화면별 엔티티 표시 설정 생성
|
||||
const screenEntityConfigs: Record<string, any> = {};
|
||||
entityJoinColumns.forEach((col) => {
|
||||
if (col.entityDisplayConfig) {
|
||||
const sourceColumn = col.entityJoinInfo!.sourceColumn;
|
||||
screenEntityConfigs[sourceColumn] = {
|
||||
displayColumns: col.entityDisplayConfig.displayColumns,
|
||||
separator: col.entityDisplayConfig.separator || " - ",
|
||||
};
|
||||
// 수동 조인 컬럼들 - 저장된 조인 정보 사용
|
||||
manualJoinColumns.forEach((col) => {
|
||||
if (col.additionalJoinInfo) {
|
||||
additionalJoinColumns.push({
|
||||
sourceTable: col.additionalJoinInfo.sourceTable,
|
||||
sourceColumn: col.additionalJoinInfo.sourceColumn,
|
||||
joinAlias: col.additionalJoinInfo.joinAlias,
|
||||
referenceTable: col.additionalJoinInfo.referenceTable,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🔗 Entity 조인 컬럼:", entityJoinColumns);
|
||||
console.log("🔗 조인 탭 컬럼:", joinTabColumns);
|
||||
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
|
||||
// console.log("🎯 화면별 엔티티 설정:", screenEntityConfigs);
|
||||
console.log("🔗 최종 추가 조인 컬럼:", additionalJoinColumns);
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page: currentPage,
|
||||
|
|
@ -591,7 +564,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
sortOrder: sortDirection,
|
||||
enableEntityJoin: true, // 🎯 Entity 조인 활성화
|
||||
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼
|
||||
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정
|
||||
});
|
||||
|
||||
if (result) {
|
||||
|
|
@ -661,16 +633,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const actualApiColumns = Object.keys(result.data[0]);
|
||||
console.log("🔍 API 응답의 실제 컬럼들:", actualApiColumns);
|
||||
|
||||
// 🎯 조인 컬럼 매핑 테이블 (사용자 설정 → API 응답)
|
||||
// 실제 API 응답에 존재하는 컬럼만 매핑
|
||||
const newJoinColumnMapping: Record<string, string> = {
|
||||
dept_code_dept_code: "dept_code", // user_info.dept_code
|
||||
dept_code_status: "status", // user_info.status (dept_info.status가 조인되지 않음)
|
||||
dept_code_company_name: "dept_name", // dept_info.dept_name (company_name이 조인되지 않음)
|
||||
dept_code_name: "dept_code_name", // dept_info.dept_name
|
||||
dept_name: "dept_name", // dept_info.dept_name
|
||||
status: "status", // user_info.status
|
||||
};
|
||||
// 🎯 조인 컬럼 매핑 테이블 - 동적 생성
|
||||
// API 응답에 실제로 존재하는 컬럼과 사용자 설정 컬럼을 비교하여 자동 매핑
|
||||
const newJoinColumnMapping: Record<string, string> = {};
|
||||
|
||||
processedColumns.forEach((col) => {
|
||||
// API 응답에 정확히 일치하는 컬럼이 있으면 그대로 사용
|
||||
if (actualApiColumns.includes(col.columnName)) {
|
||||
newJoinColumnMapping[col.columnName] = col.columnName;
|
||||
}
|
||||
});
|
||||
|
||||
// 🎯 조인 컬럼 매핑 상태 업데이트
|
||||
setJoinColumnMapping(newJoinColumnMapping);
|
||||
|
|
@ -795,7 +767,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
tableConfig.selectedTable,
|
||||
tableConfig.columns,
|
||||
currentPage,
|
||||
localPageSize,
|
||||
searchTerm,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
searchValues,
|
||||
]);
|
||||
|
||||
// 디바운싱된 테이블 데이터 가져오기
|
||||
const fetchTableDataDebounced = useCallback(
|
||||
debouncedApiCall(
|
||||
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
|
||||
async () => {
|
||||
return fetchTableDataInternal();
|
||||
},
|
||||
200, // 200ms 디바운스
|
||||
),
|
||||
[
|
||||
tableConfig.selectedTable,
|
||||
currentPage,
|
||||
localPageSize,
|
||||
searchTerm,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
searchValues,
|
||||
fetchTableDataInternal,
|
||||
],
|
||||
);
|
||||
|
||||
// 페이지 변경
|
||||
const handlePageChange = (newPage: number) => {
|
||||
|
|
@ -947,12 +949,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}, [columnLabels]);
|
||||
|
||||
// 🎯 컬럼 개수와 컬럼명을 문자열로 변환하여 의존성 추적
|
||||
const columnsKey = useMemo(() => {
|
||||
if (!tableConfig.columns) return "";
|
||||
return tableConfig.columns.map((col) => col.columnName).join(",");
|
||||
}, [tableConfig.columns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableConfig.autoLoad && !isDesignMode) {
|
||||
fetchTableDataDebounced();
|
||||
// autoLoad가 undefined거나 true일 때 자동 로드 (기본값: true)
|
||||
const shouldAutoLoad = tableConfig.autoLoad !== false;
|
||||
|
||||
console.log("🔍 TableList 데이터 로드 조건 체크:", {
|
||||
shouldAutoLoad,
|
||||
isDesignMode,
|
||||
selectedTable: tableConfig.selectedTable,
|
||||
autoLoadSetting: tableConfig.autoLoad,
|
||||
willLoad: shouldAutoLoad && !isDesignMode,
|
||||
});
|
||||
|
||||
if (shouldAutoLoad && !isDesignMode) {
|
||||
console.log("✅ 테이블 데이터 로드 시작:", tableConfig.selectedTable);
|
||||
fetchTableDataInternal();
|
||||
} else {
|
||||
console.warn("⚠️ 테이블 데이터 로드 차단:", {
|
||||
reason: !shouldAutoLoad ? "autoLoad=false" : "isDesignMode=true",
|
||||
shouldAutoLoad,
|
||||
isDesignMode,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
tableConfig.selectedTable,
|
||||
columnsKey, // 🎯 컬럼이 추가/변경될 때 데이터 다시 로드 (문자열 비교)
|
||||
localPageSize,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
|
|
@ -960,6 +987,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
sortDirection,
|
||||
columnLabels,
|
||||
searchValues,
|
||||
fetchTableDataInternal, // 의존성 배열에 추가
|
||||
]);
|
||||
|
||||
// refreshKey 변경 시 테이블 데이터 새로고침
|
||||
|
|
@ -992,7 +1020,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
|
||||
window.addEventListener("refreshTable", handleRefreshTable);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("refreshTable", handleRefreshTable);
|
||||
};
|
||||
|
|
@ -1314,35 +1342,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onDragEnd,
|
||||
};
|
||||
|
||||
// 디자인 모드에서의 플레이스홀더
|
||||
if (isDesignMode && !tableConfig.selectedTable) {
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
<div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm">
|
||||
<TableIcon className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="mb-2 text-lg font-semibold text-slate-700">테이블 리스트</div>
|
||||
<div className="rounded-full bg-white/60 px-4 py-2 text-sm text-slate-500">
|
||||
설정 패널에서 테이블을 선택해주세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 플레이스홀더 제거 - 디자인 모드에서도 바로 테이블 표시
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
"bg-white border border-gray-200/60",
|
||||
"border border-gray-200/60 bg-white",
|
||||
"rounded-2xl shadow-sm",
|
||||
"backdrop-blur-sm",
|
||||
"transition-all duration-300 ease-out",
|
||||
isSelected && "ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10",
|
||||
isSelected && "shadow-lg ring-2 shadow-blue-500/10 ring-blue-500/20",
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
|
|
@ -1350,7 +1361,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
{/* 헤더 */}
|
||||
{tableConfig.showHeader && (
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-gray-200/40 bg-gradient-to-r from-slate-50/80 to-gray-50/60 px-6 py-5"
|
||||
className="bg-muted/30 flex items-center justify-between border-b px-6 py-5"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
|
|
@ -1359,7 +1370,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{(tableConfig.title || tableLabel) && (
|
||||
<h3 className="text-xl font-semibold text-gray-800 tracking-tight">{tableConfig.title || tableLabel}</h3>
|
||||
<h3 className="text-xl font-semibold tracking-tight text-gray-800">{tableConfig.title || tableLabel}</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1377,16 +1388,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="group relative rounded-xl border-gray-200/60 bg-white/80 backdrop-blur-sm shadow-sm hover:shadow-md transition-all duration-200 hover:bg-gray-50/80"
|
||||
className="group relative rounded-xl shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<RefreshCw className={cn("h-4 w-4 text-gray-600", loading && "animate-spin")} />
|
||||
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-100/40"></div>}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{loading ? "새로고침 중..." : "새로고침"}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-700">{loading ? "새로고침 중..." : "새로고침"}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1424,7 +1433,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
{/* 테이블 컨텐츠 */}
|
||||
<div
|
||||
className={`w-full overflow-auto flex-1`}
|
||||
className={`w-full flex-1 overflow-auto`}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
|
|
@ -1622,7 +1631,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<TableCell
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-12 px-6 py-4 align-middle text-sm transition-all duration-200 text-gray-600",
|
||||
"h-12 px-6 py-4 align-middle text-sm text-gray-600 transition-all duration-200",
|
||||
`text-${column.align}`,
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -1687,9 +1696,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 푸터/페이지네이션 */}
|
||||
{tableConfig.showFooter && tableConfig.pagination?.enabled && (
|
||||
{/* showFooter와 pagination.enabled의 기본값은 true */}
|
||||
{tableConfig.showFooter !== false && tableConfig.pagination?.enabled !== false && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center space-y-4 border-t border-gray-200 bg-gray-100/80 p-6"
|
||||
className="bg-muted/30 flex flex-col items-center justify-center space-y-4 border-t p-6"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
|
|
@ -1749,7 +1759,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 데이터는 useEffect에서 자동으로 다시 로드됨
|
||||
}}
|
||||
className="rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300/60 hover:bg-white hover:shadow-md"
|
||||
className="rounded-xl border px-4 py-2 text-sm font-medium shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
|
||||
<option key={size} value={size}>
|
||||
|
|
@ -1760,13 +1770,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
)}
|
||||
|
||||
{/* 페이지네이션 버튼 */}
|
||||
<div className="flex items-center space-x-2 rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm p-1 shadow-sm">
|
||||
<div className="flex items-center space-x-2 rounded-xl border p-1 shadow-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1775,12 +1785,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center rounded-lg border border-gray-200/40 bg-gradient-to-r from-gray-50/80 to-slate-50/60 px-4 py-2 backdrop-blur-sm">
|
||||
<div className="bg-muted/30 flex items-center rounded-lg border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-gray-800">{currentPage}</span>
|
||||
<span className="mx-2 font-light text-gray-400">/</span>
|
||||
<span className="text-sm font-medium text-gray-600">{totalPages}</span>
|
||||
|
|
@ -1791,7 +1801,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1800,7 +1810,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -97,13 +97,20 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
>
|
||||
>({});
|
||||
|
||||
// 화면 테이블명이 있으면 자동으로 설정
|
||||
// 화면 테이블명이 있으면 자동으로 설정 (초기 한 번만)
|
||||
useEffect(() => {
|
||||
if (screenTableName && (!config.selectedTable || config.selectedTable !== screenTableName)) {
|
||||
console.log("🔄 화면 테이블명 자동 설정:", screenTableName);
|
||||
onChange({ selectedTable: screenTableName });
|
||||
if (screenTableName && !config.selectedTable) {
|
||||
// 기존 config의 모든 속성을 유지하면서 selectedTable만 추가/업데이트
|
||||
const updatedConfig = {
|
||||
...config,
|
||||
selectedTable: screenTableName,
|
||||
// 컬럼이 있으면 유지, 없으면 빈 배열
|
||||
columns: config.columns || [],
|
||||
};
|
||||
onChange(updatedConfig);
|
||||
}
|
||||
}, [screenTableName, config.selectedTable, onChange]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenTableName]); // config.selectedTable이 없을 때만 실행되도록 의존성 최소화
|
||||
|
||||
// 테이블 목록 가져오기
|
||||
useEffect(() => {
|
||||
|
|
@ -137,25 +144,32 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
screenTableName,
|
||||
);
|
||||
|
||||
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우에만 컬럼 목록 표시
|
||||
const shouldShowColumns = config.selectedTable || (screenTableName && config.columns && config.columns.length > 0);
|
||||
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우 컬럼 목록 표시
|
||||
const shouldShowColumns = config.selectedTable || screenTableName;
|
||||
|
||||
if (!shouldShowColumns) {
|
||||
console.log("🔧 컬럼 목록 숨김 - 명시적 테이블 선택 또는 설정된 컬럼이 없음");
|
||||
console.log("🔧 컬럼 목록 숨김 - 테이블이 선택되지 않음");
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// tableColumns prop을 우선 사용하되, 컴포넌트가 명시적으로 설정되었을 때만
|
||||
if (tableColumns && tableColumns.length > 0 && (config.selectedTable || config.columns?.length > 0)) {
|
||||
console.log("🔧 tableColumns prop 사용:", tableColumns);
|
||||
// tableColumns prop을 우선 사용
|
||||
if (tableColumns && tableColumns.length > 0) {
|
||||
const mappedColumns = tableColumns.map((column: any) => ({
|
||||
columnName: column.columnName || column.name,
|
||||
dataType: column.dataType || column.type || "text",
|
||||
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
||||
}));
|
||||
console.log("🏷️ availableColumns 설정됨:", mappedColumns);
|
||||
setAvailableColumns(mappedColumns);
|
||||
|
||||
// selectedTable이 없으면 screenTableName으로 설정
|
||||
if (!config.selectedTable && screenTableName) {
|
||||
onChange({
|
||||
...config,
|
||||
selectedTable: screenTableName,
|
||||
columns: config.columns || [],
|
||||
});
|
||||
}
|
||||
} else if (config.selectedTable || screenTableName) {
|
||||
// API에서 컬럼 정보 가져오기
|
||||
const fetchColumns = async () => {
|
||||
|
|
@ -190,7 +204,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
} else {
|
||||
setAvailableColumns([]);
|
||||
}
|
||||
}, [config.selectedTable, screenTableName, tableColumns, config.columns]);
|
||||
}, [config.selectedTable, screenTableName, tableColumns]);
|
||||
|
||||
// Entity 조인 컬럼 정보 가져오기
|
||||
useEffect(() => {
|
||||
|
|
@ -235,7 +249,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
// hasOnChange: !!onChange,
|
||||
// onChangeType: typeof onChange,
|
||||
// });
|
||||
|
||||
|
||||
const parentValue = config[parentKey] as any;
|
||||
const newConfig = {
|
||||
[parentKey]: {
|
||||
|
|
@ -243,7 +257,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
[childKey]: value,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// console.log("📤 TableListConfigPanel onChange 호출:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
|
@ -275,8 +289,30 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
|
||||
// 🎯 조인 컬럼 추가 (조인 탭에서 추가하는 컬럼들은 일반 컬럼으로 처리)
|
||||
const addEntityColumn = (joinColumn: (typeof entityJoinColumns.availableColumns)[0]) => {
|
||||
console.log("🔗 조인 컬럼 추가 요청:", {
|
||||
joinColumn,
|
||||
joinAlias: joinColumn.joinAlias,
|
||||
columnLabel: joinColumn.columnLabel,
|
||||
tableName: joinColumn.tableName,
|
||||
columnName: joinColumn.columnName,
|
||||
});
|
||||
|
||||
const existingColumn = config.columns?.find((col) => col.columnName === joinColumn.joinAlias);
|
||||
if (existingColumn) return;
|
||||
if (existingColumn) {
|
||||
console.warn("⚠️ 이미 존재하는 컬럼:", joinColumn.joinAlias);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 joinTables에서 sourceColumn 찾기
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find((jt: any) => jt.tableName === joinColumn.tableName);
|
||||
const sourceColumn = joinTableInfo?.joinConfig?.sourceColumn || "";
|
||||
|
||||
console.log("🔍 조인 정보 추출:", {
|
||||
tableName: joinColumn.tableName,
|
||||
foundJoinTable: !!joinTableInfo,
|
||||
sourceColumn,
|
||||
joinConfig: joinTableInfo?.joinConfig,
|
||||
});
|
||||
|
||||
// 조인 탭에서 추가하는 컬럼들은 일반 컬럼으로 처리 (isEntityJoin: false)
|
||||
const newColumn: ColumnConfig = {
|
||||
|
|
@ -289,10 +325,21 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
format: "text",
|
||||
order: config.columns?.length || 0,
|
||||
isEntityJoin: false, // 조인 탭에서 추가하는 컬럼은 엔티티 타입이 아님
|
||||
// 🎯 추가 조인 정보 저장
|
||||
additionalJoinInfo: {
|
||||
sourceTable: config.selectedTable || screenTableName || "", // 기준 테이블 (예: user_info)
|
||||
sourceColumn: sourceColumn, // 기준 컬럼 (예: dept_code) - joinTables에서 추출
|
||||
referenceTable: joinColumn.tableName, // 참조 테이블 (예: dept_info)
|
||||
joinAlias: joinColumn.joinAlias, // 조인 별칭 (예: dept_code_company_name)
|
||||
},
|
||||
};
|
||||
|
||||
handleChange("columns", [...(config.columns || []), newColumn]);
|
||||
console.log("🔗 조인 컬럼 추가됨 (일반 컬럼으로 처리):", newColumn);
|
||||
console.log("✅ 조인 컬럼 추가 완료:", {
|
||||
columnName: newColumn.columnName,
|
||||
displayName: newColumn.displayName,
|
||||
totalColumns: (config.columns?.length || 0) + 1,
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 제거
|
||||
|
|
@ -309,17 +356,31 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// 🎯 기존 컬럼들을 체크하여 엔티티 타입인 경우 isEntityJoin 플래그 설정
|
||||
// useRef로 이전 컬럼 개수를 추적하여 새 컬럼 추가 시에만 실행
|
||||
const prevColumnsLengthRef = React.useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLength = config.columns?.length || 0;
|
||||
const prevLength = prevColumnsLengthRef.current;
|
||||
|
||||
console.log("🔍 엔티티 컬럼 감지 useEffect 실행:", {
|
||||
hasColumns: !!config.columns,
|
||||
columnsCount: config.columns?.length || 0,
|
||||
columnsCount: currentLength,
|
||||
prevColumnsCount: prevLength,
|
||||
hasTableColumns: !!tableColumns,
|
||||
tableColumnsCount: tableColumns?.length || 0,
|
||||
selectedTable: config.selectedTable,
|
||||
});
|
||||
|
||||
if (!config.columns || !tableColumns) {
|
||||
if (!config.columns || !tableColumns || config.columns.length === 0) {
|
||||
console.log("⚠️ 컬럼 또는 테이블 컬럼 정보가 없어서 엔티티 감지 스킵");
|
||||
prevColumnsLengthRef.current = currentLength;
|
||||
return;
|
||||
}
|
||||
|
||||
// 컬럼 개수가 변경되지 않았고, 이미 체크한 적이 있으면 스킵
|
||||
if (currentLength === prevLength && prevLength > 0) {
|
||||
console.log("ℹ️ 컬럼 개수 변경 없음, 엔티티 감지 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -352,14 +413,14 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
...column,
|
||||
isEntityJoin: true,
|
||||
entityJoinInfo: {
|
||||
sourceTable: config.selectedTable || "",
|
||||
sourceTable: config.selectedTable || screenTableName || "",
|
||||
sourceColumn: column.columnName,
|
||||
joinAlias: column.columnName,
|
||||
},
|
||||
entityDisplayConfig: {
|
||||
displayColumns: [], // 빈 배열로 초기화
|
||||
separator: " - ",
|
||||
sourceTable: config.selectedTable || "",
|
||||
sourceTable: config.selectedTable || screenTableName || "",
|
||||
joinTable: tableColumn.reference_table || tableColumn.referenceTable || "",
|
||||
},
|
||||
};
|
||||
|
|
@ -377,7 +438,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
} else {
|
||||
console.log("ℹ️ 엔티티 컬럼 변경사항 없음");
|
||||
}
|
||||
}, [config.columns, tableColumns, config.selectedTable]);
|
||||
|
||||
// 현재 컬럼 개수를 저장
|
||||
prevColumnsLengthRef.current = currentLength;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.columns?.length, tableColumns, config.selectedTable]); // 컬럼 개수 변경 시에만 실행
|
||||
|
||||
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
|
||||
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
|
||||
|
|
@ -400,6 +465,15 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
// entityDisplayConfig가 없으면 초기화
|
||||
if (!column.entityDisplayConfig) {
|
||||
console.log("🔧 entityDisplayConfig 초기화:", column.columnName);
|
||||
|
||||
// sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
|
||||
const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
|
||||
|
||||
if (!initialSourceTable) {
|
||||
console.warn("⚠️ sourceTable을 결정할 수 없어서 초기화 실패:", column.columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedColumns = config.columns?.map((col) => {
|
||||
if (col.columnName === column.columnName) {
|
||||
return {
|
||||
|
|
@ -407,7 +481,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
entityDisplayConfig: {
|
||||
displayColumns: [],
|
||||
separator: " - ",
|
||||
sourceTable: config.selectedTable || "",
|
||||
sourceTable: initialSourceTable,
|
||||
joinTable: "",
|
||||
},
|
||||
};
|
||||
|
|
@ -430,15 +504,34 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
console.log("🔍 entityDisplayConfig 전체 구조:", column.entityDisplayConfig);
|
||||
console.log("🔍 entityDisplayConfig 키들:", Object.keys(column.entityDisplayConfig));
|
||||
|
||||
// sourceTable과 joinTable이 없으면 entityJoinInfo에서 가져오기
|
||||
let sourceTable = column.entityDisplayConfig.sourceTable;
|
||||
// sourceTable 결정 우선순위:
|
||||
// 1. entityDisplayConfig.sourceTable
|
||||
// 2. entityJoinInfo.sourceTable
|
||||
// 3. config.selectedTable
|
||||
// 4. screenTableName
|
||||
let sourceTable =
|
||||
column.entityDisplayConfig.sourceTable ||
|
||||
column.entityJoinInfo?.sourceTable ||
|
||||
config.selectedTable ||
|
||||
screenTableName;
|
||||
|
||||
let joinTable = column.entityDisplayConfig.joinTable;
|
||||
|
||||
if (!sourceTable && column.entityJoinInfo) {
|
||||
sourceTable = column.entityJoinInfo.sourceTable;
|
||||
// sourceTable이 여전히 비어있으면 에러
|
||||
if (!sourceTable) {
|
||||
console.error("❌ sourceTable이 비어있어서 처리 불가:", {
|
||||
columnName: column.columnName,
|
||||
entityDisplayConfig: column.entityDisplayConfig,
|
||||
entityJoinInfo: column.entityJoinInfo,
|
||||
configSelectedTable: config.selectedTable,
|
||||
screenTableName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!joinTable) {
|
||||
console.log("✅ sourceTable 결정됨:", sourceTable);
|
||||
|
||||
if (!joinTable && sourceTable) {
|
||||
// joinTable이 없으면 tableTypeApi로 조회해서 설정
|
||||
try {
|
||||
console.log("🔍 joinTable이 없어서 tableTypeApi로 조회:", sourceTable);
|
||||
|
|
@ -464,10 +557,15 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
if (updatedColumns) {
|
||||
handleChange("columns", updatedColumns);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ tableTypeApi에서 조인 테이블 정보를 찾지 못함:", column.columnName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
|
||||
console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
|
||||
}
|
||||
} else if (!joinTable) {
|
||||
console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
|
||||
}
|
||||
|
||||
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
|
||||
|
|
@ -789,15 +887,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">카드 레이아웃 설정</Label>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cards-per-row">한 행당 카드 수</Label>
|
||||
<Select
|
||||
value={config.cardConfig?.cardsPerRow?.toString() || "3"}
|
||||
onValueChange={(value) =>
|
||||
handleNestedChange("cardConfig", "cardsPerRow", parseInt(value))
|
||||
}
|
||||
onValueChange={(value) => handleNestedChange("cardConfig", "cardsPerRow", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
|
@ -819,9 +915,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
id="card-spacing"
|
||||
type="number"
|
||||
value={config.cardConfig?.cardSpacing || 16}
|
||||
onChange={(e) =>
|
||||
handleNestedChange("cardConfig", "cardSpacing", parseInt(e.target.value))
|
||||
}
|
||||
onChange={(e) => handleNestedChange("cardConfig", "cardSpacing", parseInt(e.target.value))}
|
||||
min="0"
|
||||
max="50"
|
||||
/>
|
||||
|
|
@ -830,15 +924,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">카드 필드 매핑</Label>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="id-column">ID 컬럼 (사번 등)</Label>
|
||||
<Select
|
||||
value={config.cardConfig?.idColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleNestedChange("cardConfig", "idColumn", value)
|
||||
}
|
||||
onValueChange={(value) => handleNestedChange("cardConfig", "idColumn", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="ID 컬럼 선택" />
|
||||
|
|
@ -857,9 +949,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<Label htmlFor="title-column">제목 컬럼 (이름 등)</Label>
|
||||
<Select
|
||||
value={config.cardConfig?.titleColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleNestedChange("cardConfig", "titleColumn", value)
|
||||
}
|
||||
onValueChange={(value) => handleNestedChange("cardConfig", "titleColumn", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="제목 컬럼 선택" />
|
||||
|
|
@ -878,9 +968,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<Label htmlFor="subtitle-column">서브 제목 컬럼 (부서 등)</Label>
|
||||
<Select
|
||||
value={config.cardConfig?.subtitleColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleNestedChange("cardConfig", "subtitleColumn", value)
|
||||
}
|
||||
onValueChange={(value) => handleNestedChange("cardConfig", "subtitleColumn", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="서브 제목 컬럼 선택 (선택사항)" />
|
||||
|
|
@ -900,9 +988,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<Label htmlFor="description-column">설명 컬럼</Label>
|
||||
<Select
|
||||
value={config.cardConfig?.descriptionColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleNestedChange("cardConfig", "descriptionColumn", value)
|
||||
}
|
||||
onValueChange={(value) => handleNestedChange("cardConfig", "descriptionColumn", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설명 컬럼 선택 (선택사항)" />
|
||||
|
|
@ -924,7 +1010,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id="show-card-actions"
|
||||
checked={config.cardConfig?.showActions !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
onCheckedChange={(checked) =>
|
||||
handleNestedChange("cardConfig", "showActions", checked as boolean)
|
||||
}
|
||||
/>
|
||||
|
|
@ -1270,7 +1356,34 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadEntityDisplayConfig(column)}
|
||||
onClick={() => {
|
||||
// sourceTable 정보가 있는지 확인
|
||||
const hasSourceTable =
|
||||
column.entityDisplayConfig?.sourceTable ||
|
||||
column.entityJoinInfo?.sourceTable ||
|
||||
config.selectedTable ||
|
||||
screenTableName;
|
||||
|
||||
if (!hasSourceTable) {
|
||||
console.error("❌ sourceTable 정보를 찾을 수 없어서 컬럼 로드 불가:", {
|
||||
columnName: column.columnName,
|
||||
entityDisplayConfig: column.entityDisplayConfig,
|
||||
entityJoinInfo: column.entityJoinInfo,
|
||||
configSelectedTable: config.selectedTable,
|
||||
screenTableName,
|
||||
});
|
||||
alert("컬럼 정보를 로드할 수 없습니다. 테이블 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
loadEntityDisplayConfig(column);
|
||||
}}
|
||||
disabled={
|
||||
!column.entityDisplayConfig?.sourceTable &&
|
||||
!column.entityJoinInfo?.sourceTable &&
|
||||
!config.selectedTable &&
|
||||
!screenTableName
|
||||
}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -72,21 +72,29 @@ export interface ColumnConfig {
|
|||
// 새로운 기능들
|
||||
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||
|
||||
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
|
||||
additionalJoinInfo?: {
|
||||
sourceTable: string; // 원본 테이블
|
||||
sourceColumn: string; // 원본 컬럼 (예: dept_code)
|
||||
referenceTable?: string; // 참조 테이블 (예: dept_info)
|
||||
joinAlias: string; // 조인 별칭 (예: dept_code_company_name)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 디스플레이 설정
|
||||
*/
|
||||
export interface CardDisplayConfig {
|
||||
idColumn: string; // ID 컬럼 (사번 등)
|
||||
titleColumn: string; // 제목 컬럼 (이름 등)
|
||||
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
|
||||
idColumn: string; // ID 컬럼 (사번 등)
|
||||
titleColumn: string; // 제목 컬럼 (이름 등)
|
||||
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
|
||||
descriptionColumn?: string; // 설명 컬럼
|
||||
imageColumn?: string; // 이미지 컬럼
|
||||
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
|
||||
cardSpacing: number; // 카드 간격 (기본: 16px)
|
||||
showActions: boolean; // 액션 버튼 표시 여부
|
||||
cardHeight?: number; // 카드 높이 (기본: auto)
|
||||
imageColumn?: string; // 이미지 컬럼
|
||||
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
|
||||
cardSpacing: number; // 카드 간격 (기본: 16px)
|
||||
showActions: boolean; // 액션 버튼 표시 여부
|
||||
cardHeight?: number; // 카드 높이 (기본: auto)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,11 +171,11 @@ export interface CheckboxConfig {
|
|||
*/
|
||||
export interface TableListConfig extends ComponentConfig {
|
||||
// 표시 모드 설정
|
||||
displayMode?: "table" | "card"; // 기본: "table"
|
||||
|
||||
displayMode?: "table" | "card"; // 기본: "table"
|
||||
|
||||
// 카드 디스플레이 설정 (displayMode가 "card"일 때 사용)
|
||||
cardConfig?: CardDisplayConfig;
|
||||
|
||||
|
||||
// 테이블 기본 설정
|
||||
selectedTable?: string;
|
||||
tableName?: string;
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
color: componentConfig.color || "#212121",
|
||||
textAlign: componentConfig.textAlign || "left",
|
||||
backgroundColor: componentConfig.backgroundColor || "transparent",
|
||||
padding: componentConfig.padding || "8px 12px",
|
||||
borderRadius: componentConfig.borderRadius || "8px",
|
||||
border: componentConfig.border || "1px solid #e5e7eb",
|
||||
padding: componentConfig.padding || "0",
|
||||
borderRadius: componentConfig.borderRadius || "0",
|
||||
border: componentConfig.border || "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
|
|
@ -91,7 +91,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
wordBreak: "break-word",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
boxShadow: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const ToggleSwitchDefinition = createComponentDefinition({
|
|||
name: "토글 스위치",
|
||||
nameEng: "ToggleSwitch Component",
|
||||
description: "ON/OFF 상태 전환을 위한 토글 스위치 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "boolean",
|
||||
component: ToggleSwitchWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -210,6 +210,12 @@ export class EnhancedFormService {
|
|||
* 테이블 컬럼 정보 조회 (캐시 포함)
|
||||
*/
|
||||
private async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
||||
// tableName이 비어있으면 빈 배열 반환
|
||||
if (!tableName || tableName.trim() === "") {
|
||||
console.warn("⚠️ getTableColumns: tableName이 비어있음");
|
||||
return [];
|
||||
}
|
||||
|
||||
// 캐시 확인
|
||||
const cached = this.columnCache.get(tableName);
|
||||
if (cached) {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ interface FlowEditorState {
|
|||
flowDescription: string;
|
||||
|
||||
// UI 상태
|
||||
isExecuting: boolean;
|
||||
isSaving: boolean;
|
||||
showValidationPanel: boolean;
|
||||
showPropertiesPanel: boolean;
|
||||
|
|
@ -131,7 +130,6 @@ interface FlowEditorState {
|
|||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value: boolean) => void;
|
||||
setIsSaving: (value: boolean) => void;
|
||||
setShowValidationPanel: (value: boolean) => void;
|
||||
setShowPropertiesPanel: (value: boolean) => void;
|
||||
|
|
@ -169,7 +167,6 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
|
|||
flowId: null,
|
||||
flowName: "새 제어 플로우",
|
||||
flowDescription: "",
|
||||
isExecuting: false,
|
||||
isSaving: false,
|
||||
showValidationPanel: false,
|
||||
showPropertiesPanel: true,
|
||||
|
|
@ -599,7 +596,6 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
|
|||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value) => set({ isExecuting: value }),
|
||||
setIsSaving: (value) => set({ isSaving: value }),
|
||||
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
||||
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
||||
|
|
|
|||
|
|
@ -7,48 +7,59 @@ import { ComponentData } from "@/types/screen";
|
|||
|
||||
/**
|
||||
* 파일 컴포넌트 여부를 확인합니다
|
||||
*
|
||||
*
|
||||
* 지원하는 타입:
|
||||
* - 레거시: type="file"
|
||||
* - 레거시: type="widget" + widgetType="file"
|
||||
* - 레거시: type="widget" + widgetType="file"
|
||||
* - 신규: type="component" + widgetType="file"
|
||||
* - 신규: type="component" + componentType="file-upload"
|
||||
* - 신규: type="component" + componentConfig.webType="file"
|
||||
*/
|
||||
export const isFileComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "file" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "file") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).widgetType === "file" || // ✅ ScreenDesigner에서 설정됨
|
||||
(component as any).componentType === "file-upload" || // ✅ ComponentRegistry ID
|
||||
(component as any).componentConfig?.webType === "file")); // ✅ componentConfig 내부
|
||||
if (!component || !component.type) return false;
|
||||
|
||||
return (
|
||||
component.type === "file" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "file") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).widgetType === "file" || // ✅ ScreenDesigner에서 설정됨
|
||||
(component as any).componentType === "file-upload" || // ✅ ComponentRegistry ID
|
||||
(component as any).componentConfig?.webType === "file"))
|
||||
); // ✅ componentConfig 내부
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼 컴포넌트 여부를 확인합니다
|
||||
*/
|
||||
export const isButtonComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "button" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).webType === "button" ||
|
||||
(component as any).componentType === "button"));
|
||||
if (!component || !component.type) return false;
|
||||
|
||||
return (
|
||||
component.type === "button" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).webType === "button" || (component as any).componentType === "button"))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터 테이블 컴포넌트 여부를 확인합니다
|
||||
*/
|
||||
export const isDataTableComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "datatable" ||
|
||||
(component.type === "component" &&
|
||||
((component as any).componentType === "datatable" ||
|
||||
(component as any).componentType === "data-table"));
|
||||
if (!component || !component.type) return false;
|
||||
|
||||
return (
|
||||
component.type === "datatable" ||
|
||||
(component.type === "component" &&
|
||||
((component as any).componentType === "datatable" || (component as any).componentType === "data-table"))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 위젯 컴포넌트 여부를 확인합니다
|
||||
*/
|
||||
export const isWidgetComponent = (component: ComponentData): boolean => {
|
||||
if (!component || !component.type) return false;
|
||||
return component.type === "widget";
|
||||
};
|
||||
|
||||
|
|
@ -56,17 +67,19 @@ export const isWidgetComponent = (component: ComponentData): boolean => {
|
|||
* 컴포넌트의 웹타입을 가져옵니다
|
||||
*/
|
||||
export const getComponentWebType = (component: ComponentData): string | undefined => {
|
||||
if (!component || !component.type) return undefined;
|
||||
|
||||
// 파일 컴포넌트는 무조건 "file" 웹타입 반환
|
||||
if (isFileComponent(component)) {
|
||||
console.log(`🎯 파일 컴포넌트 감지 → webType: "file" 반환`, {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
widgetType: (component as any).widgetType,
|
||||
componentConfig: (component as any).componentConfig
|
||||
componentConfig: (component as any).componentConfig,
|
||||
});
|
||||
return "file";
|
||||
}
|
||||
|
||||
|
||||
if (component.type === "widget") {
|
||||
return (component as any).widgetType;
|
||||
}
|
||||
|
|
@ -80,6 +93,8 @@ export const getComponentWebType = (component: ComponentData): string | undefine
|
|||
* 컴포넌트의 실제 타입을 가져옵니다 (신규 시스템용)
|
||||
*/
|
||||
export const getComponentType = (component: ComponentData): string => {
|
||||
if (!component || !component.type) return "unknown";
|
||||
|
||||
if (component.type === "component") {
|
||||
return (component as any).componentType || (component as any).webType || "unknown";
|
||||
}
|
||||
|
|
@ -90,10 +105,26 @@ export const getComponentType = (component: ComponentData): string => {
|
|||
* 컴포넌트가 입력 가능한 컴포넌트인지 확인합니다
|
||||
*/
|
||||
export const isInputComponent = (component: ComponentData): boolean => {
|
||||
const inputTypes = ["text", "number", "email", "password", "tel", "url", "search",
|
||||
"textarea", "select", "checkbox", "radio", "date", "time",
|
||||
"datetime-local", "file", "code", "entity"];
|
||||
|
||||
const inputTypes = [
|
||||
"text",
|
||||
"number",
|
||||
"email",
|
||||
"password",
|
||||
"tel",
|
||||
"url",
|
||||
"search",
|
||||
"textarea",
|
||||
"select",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"date",
|
||||
"time",
|
||||
"datetime-local",
|
||||
"file",
|
||||
"code",
|
||||
"entity",
|
||||
];
|
||||
|
||||
const webType = getComponentWebType(component);
|
||||
return webType ? inputTypes.includes(webType) : false;
|
||||
};
|
||||
|
|
@ -103,7 +134,7 @@ export const isInputComponent = (component: ComponentData): boolean => {
|
|||
*/
|
||||
export const isDisplayComponent = (component: ComponentData): boolean => {
|
||||
const displayTypes = ["label", "text", "image", "video", "chart", "table", "datatable"];
|
||||
|
||||
|
||||
const webType = getComponentWebType(component);
|
||||
return webType ? displayTypes.includes(webType) : false;
|
||||
};
|
||||
|
|
@ -112,12 +143,14 @@ export const isDisplayComponent = (component: ComponentData): boolean => {
|
|||
* 컴포넌트의 필드명을 가져옵니다
|
||||
*/
|
||||
export const getComponentFieldName = (component: ComponentData): string => {
|
||||
return (component as any).columnName || component.id;
|
||||
if (!component) return "";
|
||||
return (component as any).columnName || component.id || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 라벨을 가져옵니다
|
||||
*/
|
||||
export const getComponentLabel = (component: ComponentData): string => {
|
||||
return (component as any).label || (component as any).title || component.id;
|
||||
if (!component) return "";
|
||||
return (component as any).label || (component as any).title || component.id || "";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"),
|
||||
"table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),
|
||||
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
|
||||
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
||||
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -49,7 +51,10 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
|||
const module = await importFn();
|
||||
|
||||
// 모듈에서 ConfigPanel 컴포넌트 추출
|
||||
const ConfigPanelComponent = module[`${toPascalCase(componentId)}ConfigPanel`] || module.default;
|
||||
const ConfigPanelComponent =
|
||||
module[`${toPascalCase(componentId)}ConfigPanel`] ||
|
||||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||
module.default;
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
console.error(`컴포넌트 "${componentId}"의 ConfigPanel을 모듈에서 찾을 수 없습니다.`);
|
||||
|
|
@ -101,6 +106,7 @@ export interface ComponentConfigPanelProps {
|
|||
onChange: (config: Record<string, any>) => void;
|
||||
screenTableName?: string; // 화면에서 지정한 테이블명
|
||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||
tables?: any[]; // 전체 테이블 목록
|
||||
}
|
||||
|
||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||
|
|
@ -109,12 +115,16 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
onChange,
|
||||
screenTableName,
|
||||
tableColumns,
|
||||
tables,
|
||||
}) => {
|
||||
console.log(`🔥 DynamicComponentConfigPanel 렌더링 시작: ${componentId}`);
|
||||
|
||||
|
||||
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [selectedTableColumns, setSelectedTableColumns] = React.useState(tableColumns);
|
||||
const [allTablesList, setAllTablesList] = React.useState<any[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
|
@ -148,6 +158,29 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
};
|
||||
}, [componentId]);
|
||||
|
||||
// tableColumns가 변경되면 selectedTableColumns도 업데이트
|
||||
React.useEffect(() => {
|
||||
setSelectedTableColumns(tableColumns);
|
||||
}, [tableColumns]);
|
||||
|
||||
// RepeaterConfigPanel인 경우에만 전체 테이블 목록 로드
|
||||
React.useEffect(() => {
|
||||
if (componentId === "repeater-field-group") {
|
||||
const loadAllTables = async () => {
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTablesList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("전체 테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}
|
||||
}, [componentId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
|
|
@ -187,18 +220,68 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
ConfigPanelComponent: ConfigPanelComponent?.name,
|
||||
config,
|
||||
configType: typeof config,
|
||||
configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object',
|
||||
configKeys: typeof config === "object" ? Object.keys(config || {}) : "not object",
|
||||
screenTableName,
|
||||
tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns
|
||||
tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns,
|
||||
tables: Array.isArray(tables) ? tables.length : tables,
|
||||
tablesType: typeof tables,
|
||||
tablesDetail: tables, // 전체 테이블 목록 확인
|
||||
});
|
||||
|
||||
// 테이블 변경 핸들러 - 선택된 테이블의 컬럼을 동적으로 로드
|
||||
const handleTableChange = async (tableName: string) => {
|
||||
console.log("🔄 테이블 변경:", tableName);
|
||||
try {
|
||||
// 먼저 tables에서 찾아보기 (이미 컬럼이 있는 경우)
|
||||
const existingTable = tables?.find((t) => t.tableName === tableName);
|
||||
if (existingTable && existingTable.columns && existingTable.columns.length > 0) {
|
||||
console.log("✅ 캐시된 테이블 컬럼 사용:", existingTable.columns.length, "개");
|
||||
setSelectedTableColumns(existingTable.columns);
|
||||
return;
|
||||
}
|
||||
|
||||
// 컬럼이 없으면 tableTypeApi로 조회 (ScreenDesigner와 동일한 방식)
|
||||
console.log("🔍 테이블 컬럼 API 조회:", tableName);
|
||||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
console.log("🔍 컬럼 응답 데이터:", columnsResponse);
|
||||
|
||||
const columns = (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,
|
||||
}));
|
||||
|
||||
console.log("✅ 테이블 컬럼 로드 성공:", columns.length, "개");
|
||||
setSelectedTableColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 변경 오류:", error);
|
||||
// 오류 발생 시 빈 배열
|
||||
setSelectedTableColumns([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
|
||||
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
|
||||
screenTableName={screenTableName}
|
||||
tableColumns={tableColumns}
|
||||
tableColumns={selectedTableColumns} // 동적으로 변경되는 컬럼 전달
|
||||
tables={tables} // 기본 테이블 목록 (현재 화면의 테이블만)
|
||||
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
||||
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* 반응형 스마트 기본값 생성 유틸리티
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen-management";
|
||||
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
|
||||
|
||||
/**
|
||||
* 컴포넌트 크기에 따른 스마트 기본값 생성
|
||||
*
|
||||
* 로직:
|
||||
* - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지
|
||||
* - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장
|
||||
* - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비
|
||||
*/
|
||||
export function generateSmartDefaults(
|
||||
component: ComponentData,
|
||||
screenWidth: number = 1920,
|
||||
rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수
|
||||
): ResponsiveComponentConfig["responsive"] {
|
||||
// 특정 컴포넌트는 항상 전체 너비 (datatable, table-list 등)
|
||||
const fullWidthComponents = ["datatable", "data-table", "table-list", "repeater-field-group"];
|
||||
const componentId = (component as any).componentId || (component as any).id;
|
||||
const componentType = (component as any).componentType || component.type;
|
||||
|
||||
if (fullWidthComponents.includes(componentId) || fullWidthComponents.includes(componentType)) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 12, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 8, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const componentWidthPercent = (component.size.width / screenWidth) * 100;
|
||||
|
||||
// 같은 행에 여러 컴포넌트가 있으면 컬럼을 나눔
|
||||
if (rowComponentCount > 1) {
|
||||
const desktopColumns = Math.round(12 / rowComponentCount);
|
||||
const tabletColumns = Math.round(8 / rowComponentCount);
|
||||
const mobileColumns = 4; // 모바일에서는 항상 전체 너비
|
||||
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: desktopColumns,
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: tabletColumns,
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: mobileColumns,
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 매우 작은 컴포넌트 (10% 이하, 예: 버튼)
|
||||
else if (componentWidthPercent <= 10) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 1, // 12컬럼 중 1개 (~8%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 1, // 8컬럼 중 1개 (~12.5%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 1, // 4컬럼 중 1개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 작은 컴포넌트 (10-25%)
|
||||
else if (componentWidthPercent <= 25) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 3, // 12컬럼 중 3개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 2, // 8컬럼 중 2개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 1, // 4컬럼 중 1개 (25%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 중간 컴포넌트 (25-50%)
|
||||
else if (componentWidthPercent <= 50) {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 6, // 12컬럼 중 6개 (50%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 4, // 8컬럼 중 4개 (50%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 4컬럼 전체 (100%)
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 큰 컴포넌트 (50% 이상)
|
||||
else {
|
||||
return {
|
||||
desktop: {
|
||||
gridColumns: 12, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
tablet: {
|
||||
gridColumns: 8, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
mobile: {
|
||||
gridColumns: 4, // 전체 너비
|
||||
order: 1,
|
||||
hide: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트에 반응형 설정이 없을 경우 자동 생성
|
||||
*/
|
||||
export function ensureResponsiveConfig(component: ComponentData, screenWidth?: number): ComponentData {
|
||||
if (component.responsiveConfig) {
|
||||
return component;
|
||||
}
|
||||
|
||||
return {
|
||||
...component,
|
||||
responsiveConfig: {
|
||||
designerPosition: {
|
||||
x: component.position.x,
|
||||
y: component.position.y,
|
||||
width: component.size.width,
|
||||
height: component.size.height,
|
||||
},
|
||||
useSmartDefaults: true,
|
||||
responsive: generateSmartDefaults(component, screenWidth),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* 반복 필드 그룹(Repeater) 타입 정의
|
||||
*/
|
||||
|
||||
export type RepeaterFieldType = "text" | "number" | "email" | "tel" | "date" | "select" | "textarea";
|
||||
|
||||
/**
|
||||
* 반복 그룹 내 개별 필드 정의
|
||||
*/
|
||||
export interface RepeaterFieldDefinition {
|
||||
name: string; // 필드 이름 (키)
|
||||
label: string; // 필드 라벨
|
||||
type: RepeaterFieldType; // 입력 타입
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: Array<{ label: string; value: string }>; // select용
|
||||
width?: string; // 필드 너비 (예: "200px", "50%")
|
||||
validation?: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 필드 그룹 설정
|
||||
*/
|
||||
export interface RepeaterFieldGroupConfig {
|
||||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
addButtonText?: string; // 추가 버튼 텍스트
|
||||
removeButtonText?: string; // 제거 버튼 텍스트 (보통 아이콘)
|
||||
allowReorder?: boolean; // 순서 변경 가능 여부
|
||||
showIndex?: boolean; // 인덱스 번호 표시 여부
|
||||
collapsible?: boolean; // 각 항목을 접을 수 있는지 (카드 모드일 때만)
|
||||
layout?: "grid" | "card"; // 레이아웃 타입: grid(테이블 행) 또는 card(카드 형식)
|
||||
showDivider?: boolean; // 항목 사이 구분선 표시 (카드 모드일 때만)
|
||||
emptyMessage?: string; // 항목이 없을 때 메시지
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 그룹 항목 데이터
|
||||
*/
|
||||
export type RepeaterItemData = Record<string, any>;
|
||||
|
||||
/**
|
||||
* 반복 그룹 전체 데이터 (배열)
|
||||
*/
|
||||
export type RepeaterData = RepeaterItemData[];
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* 반응형 레이아웃 시스템 타입 정의
|
||||
*/
|
||||
|
||||
/**
|
||||
* 브레이크포인트 타입 정의
|
||||
*/
|
||||
export type Breakpoint = "desktop" | "tablet" | "mobile";
|
||||
|
||||
/**
|
||||
* 브레이크포인트별 설정
|
||||
*/
|
||||
export interface BreakpointConfig {
|
||||
minWidth: number; // 최소 너비 (px)
|
||||
maxWidth?: number; // 최대 너비 (px)
|
||||
columns: number; // 그리드 컬럼 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 브레이크포인트 설정
|
||||
*/
|
||||
export const BREAKPOINTS: Record<Breakpoint, BreakpointConfig> = {
|
||||
desktop: {
|
||||
minWidth: 1200,
|
||||
columns: 12,
|
||||
},
|
||||
tablet: {
|
||||
minWidth: 768,
|
||||
maxWidth: 1199,
|
||||
columns: 8,
|
||||
},
|
||||
mobile: {
|
||||
minWidth: 0,
|
||||
maxWidth: 767,
|
||||
columns: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 브레이크포인트별 반응형 설정
|
||||
*/
|
||||
export interface ResponsiveBreakpointConfig {
|
||||
gridColumns?: number; // 차지할 컬럼 수 (1-12)
|
||||
order?: number; // 정렬 순서
|
||||
hide?: boolean; // 숨김 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 반응형 설정
|
||||
*/
|
||||
export interface ResponsiveComponentConfig {
|
||||
// 기본값 (디자이너에서 설정한 절대 위치)
|
||||
designerPosition: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// 반응형 설정 (선택적)
|
||||
responsive?: {
|
||||
desktop?: ResponsiveBreakpointConfig;
|
||||
tablet?: ResponsiveBreakpointConfig;
|
||||
mobile?: ResponsiveBreakpointConfig;
|
||||
};
|
||||
|
||||
// 스마트 기본값 사용 여부
|
||||
useSmartDefaults?: boolean;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
isWebType,
|
||||
} from "./unified-core";
|
||||
import { ColumnSpanPreset } from "@/lib/constants/columnSpans";
|
||||
import { ResponsiveComponentConfig } from "./responsive";
|
||||
|
||||
// ===== 기본 컴포넌트 인터페이스 =====
|
||||
|
||||
|
|
@ -50,6 +51,10 @@ export interface BaseComponent {
|
|||
componentConfig?: any; // 컴포넌트별 설정
|
||||
componentType?: string; // 새 컴포넌트 시스템의 ID
|
||||
webTypeConfig?: WebTypeConfig; // 웹타입별 설정
|
||||
|
||||
// 반응형 설정
|
||||
responsiveConfig?: ResponsiveComponentConfig;
|
||||
responsiveDisplay?: any; // 런타임에 추가되는 임시 필드
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,6 +83,7 @@ export interface WidgetComponent extends BaseComponent {
|
|||
fileConfig?: FileTypeConfig;
|
||||
entityConfig?: EntityTypeConfig;
|
||||
buttonConfig?: ButtonTypeConfig;
|
||||
arrayConfig?: ArrayTypeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,6 +214,20 @@ export interface TextTypeConfig {
|
|||
rows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열(다중 입력) 타입 설정
|
||||
*/
|
||||
export interface ArrayTypeConfig {
|
||||
itemType?: "text" | "number" | "email" | "tel"; // 각 항목의 입력 타입
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
placeholder?: string; // 입력 필드 placeholder
|
||||
addButtonText?: string; // + 버튼 텍스트
|
||||
removeButtonText?: string; // - 버튼 텍스트 (보통 아이콘)
|
||||
allowReorder?: boolean; // 순서 변경 가능 여부
|
||||
showIndex?: boolean; // 인덱스 번호 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 타입 설정
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@
|
|||
"": {
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@react-three/drei": "^10.7.6",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
"prisma": "^6.16.2",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
|
|
@ -275,12 +278,45 @@
|
|||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@js-joda/core": {
|
||||
"version": "5.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.6.5.tgz",
|
||||
"integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.17",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
|
||||
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@monogrid/gainmap-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"promise-worker-transferable": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz",
|
||||
|
|
@ -360,6 +396,160 @@
|
|||
"@prisma/debug": "6.16.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/drei": {
|
||||
"version": "10.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.6.tgz",
|
||||
"integrity": "sha512-ZSFwRlRaa4zjtB7yHO6Q9xQGuyDCzE7whXBhum92JslcMRC3aouivp0rAzszcVymIoJx6PXmibyP+xr+zKdwLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@mediapipe/tasks-vision": "0.10.17",
|
||||
"@monogrid/gainmap-js": "^3.0.6",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"camera-controls": "^3.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"detect-gpu": "^5.0.56",
|
||||
"glsl-noise": "^0.0.0",
|
||||
"hls.js": "^1.5.17",
|
||||
"maath": "^0.10.8",
|
||||
"meshline": "^3.3.1",
|
||||
"stats-gl": "^2.2.8",
|
||||
"stats.js": "^0.17.0",
|
||||
"suspend-react": "^0.1.3",
|
||||
"three-mesh-bvh": "^0.8.3",
|
||||
"three-stdlib": "^2.35.6",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"tunnel-rat": "^0.1.2",
|
||||
"use-sync-external-store": "^1.4.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-three/fiber": "^9.0.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"three": ">=0.159"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/drei/node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/fiber": {
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
"@types/webxr": "*",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
"its-fine": "^2.0.0",
|
||||
"react-reconciler": "^0.31.0",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"scheduler": "^0.25.0",
|
||||
"suspend-react": "^0.1.3",
|
||||
"use-sync-external-store": "^1.4.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": ">=43.0",
|
||||
"expo-asset": ">=8.4",
|
||||
"expo-file-system": ">=11.0",
|
||||
"expo-gl": ">=11.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-native": ">=0.78",
|
||||
"three": ">=0.156"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-asset": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-file-system": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-gl": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/fiber/node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-three/fiber/node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
|
|
@ -372,6 +562,12 @@
|
|||
"integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
|
|
@ -421,6 +617,12 @@
|
|||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/draco3d": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mssql": {
|
||||
"version": "9.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.8.tgz",
|
||||
|
|
@ -441,6 +643,12 @@
|
|||
"undici-types": "~7.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/oracledb": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.9.1.tgz",
|
||||
|
|
@ -463,6 +671,25 @@
|
|||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-reconciler": {
|
||||
"version": "0.32.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz",
|
||||
"integrity": "sha512-gjcm6O0aUknhYaogEl8t5pecPfiOTD8VQkbjOhgbZas/E6qGY+veW9iuJU/7p4Y1E0EuQ0mArga7VEOUWSlVRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.21.tgz",
|
||||
|
|
@ -472,6 +699,33 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.1.tgz",
|
||||
|
|
@ -486,6 +740,30 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@use-gesture/core": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@use-gesture/react": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@use-gesture/core": "10.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz",
|
||||
"integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
||||
|
|
@ -576,6 +854,15 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.3.tgz",
|
||||
|
|
@ -674,6 +961,19 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camera-controls": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz",
|
||||
"integrity": "sha512-w5oULNpijgTRH0ARFJJ0R5ct1nUM3R3WP7/b8A6j9uTGpRfnsypc/RBMPQV8JQDPayUe37p/TZZY1PcUr4czOQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.11.0",
|
||||
"npm": ">=10.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.126.1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
|
|
@ -740,6 +1040,45 @@
|
|||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
|
|
@ -932,6 +1271,15 @@
|
|||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-gpu": {
|
||||
"version": "5.0.70",
|
||||
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
|
||||
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"webgl-constants": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
|
|
@ -944,6 +1292,12 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -1077,6 +1431,12 @@
|
|||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
|
|
@ -1176,6 +1536,12 @@
|
|||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/glsl-noise": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
|
||||
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
|
|
@ -1227,6 +1593,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.13",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
|
||||
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -1285,6 +1657,12 @@
|
|||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
|
@ -1324,6 +1702,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
|
||||
|
|
@ -1339,6 +1723,33 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/its-fine": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
|
||||
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.28.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/its-fine/node_modules/@types/react-reconciler": {
|
||||
"version": "0.28.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
|
||||
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||
|
|
@ -1397,6 +1808,15 @@
|
|||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
|
|
@ -1439,6 +1859,16 @@
|
|||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/maath": {
|
||||
"version": "0.10.8",
|
||||
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
|
||||
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/three": ">=0.134.0",
|
||||
"three": ">=0.134.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -1448,6 +1878,21 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/meshline": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
|
||||
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.137"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
|
|
@ -1550,6 +1995,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
|
|
@ -1650,6 +2104,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
|
||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
|
||||
|
|
@ -1684,6 +2144,16 @@
|
|||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"is-promise": "^2.1.0",
|
||||
"lie": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
|
|
@ -1739,6 +2209,42 @@
|
|||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.31.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
|
||||
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13",
|
||||
"react-dom": ">=16.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
|
|
@ -1768,6 +2274,15 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
|
|
@ -1831,12 +2346,59 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stats-gl": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
|
||||
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl/node_modules/three": {
|
||||
"version": "0.170.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
|
||||
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stats.js": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
|
||||
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
|
@ -1846,6 +2408,15 @@
|
|||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/suspend-react": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
|
||||
"integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tarn": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
|
||||
|
|
@ -1876,18 +2447,95 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
|
||||
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib": {
|
||||
"version": "2.36.0",
|
||||
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.0.tgz",
|
||||
"integrity": "sha512-kv0Byb++AXztEGsULgMAs8U2jgUdz6HPpAB/wDJnLiLlaWQX2APHhiTJIN7rqW+Of0eRgcp7jn05U1BsCP3xBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/draco3d": "^1.4.0",
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"@types/webxr": "^0.5.2",
|
||||
"draco3d": "^1.4.1",
|
||||
"fflate": "^0.6.9",
|
||||
"potpack": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.128.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib/node_modules/fflate": {
|
||||
"version": "0.6.10",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
|
||||
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/troika-three-text": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.2",
|
||||
"troika-three-utils": "^0.52.4",
|
||||
"troika-worker-utils": "^0.52.0",
|
||||
"webgl-sdf-generator": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-utils": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
|
||||
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-worker-utils": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
|
||||
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-rat": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
|
||||
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zustand": "^4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
|
||||
|
|
@ -1903,6 +2551,15 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
|
@ -1912,6 +2569,32 @@
|
|||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
|
||||
},
|
||||
"node_modules/webgl-sdf-generator": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
|
||||
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@react-three/drei": "^10.7.6",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
"prisma": "^6.16.2",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue