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:
kjs 2025-10-17 17:15:55 +09:00
commit 6603ff81fe
81 changed files with 8929 additions and 2377 deletions

998
PHASE_RESPONSIVE_LAYOUT.md Normal file
View File

@ -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부터 순차적으로 구현을 시작합니다.

View File

@ -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(),

View File

@ -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,

View File

@ -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;

View File

@ -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);

View File

@ -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();

View File

@ -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}`);
}
}
/**
* ( )
*/

View File

@ -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 = [

View File

@ -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,
]
);
}
/**
* -
*/

View File

@ -112,9 +112,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
json: "textarea",
jsonb: "textarea",
// 배열 타입 (텍스트로 처리)
ARRAY: "textarea",
// UUID 타입
uuid: "text",
};

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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

View File

@ -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;
}

View File

@ -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>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<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>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
&ldquo;{selectedFlow?.flowName}&rdquo; ?
<br />
<span className="font-medium text-red-600">
, .
, .
</span>
</DialogDescription>
</DialogHeader>

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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 }}

View File

@ -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;

View File

@ -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>

View File

@ -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>>({});

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지

View File

@ -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 && (

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View File

@ -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>
))}

View File

@ -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>
);

View File

@ -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}

View File

@ -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";

View File

@ -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";

View File

@ -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;
}

108
frontend/lib/api/data.ts Normal file
View File

@ -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}`);
},
};

View File

@ -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,

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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 : {}),
}}

View File

@ -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>
);
};

View File

@ -17,7 +17,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
name: "체크박스",
nameEng: "CheckboxBasic Component",
description: "체크 상태 선택을 위한 체크박스 컴포넌트",
category: ComponentCategory.INPUT,
category: ComponentCategory.FORM,
webType: "checkbox",
component: CheckboxBasicWrapper,
defaultConfig: {

View File

@ -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
`,
};

View File

@ -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";
/**
*

View File

@ -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>

View File

@ -17,7 +17,7 @@ export const RadioBasicDefinition = createComponentDefinition({
name: "라디오 버튼",
nameEng: "RadioBasic Component",
description: "단일 옵션 선택을 위한 라디오 버튼 그룹 컴포넌트",
category: ComponentCategory.INPUT,
category: ComponentCategory.FORM,
webType: "radio",
component: RadioBasicWrapper,
defaultConfig: {

View File

@ -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();
}

View File

@ -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>

View File

@ -17,7 +17,7 @@ export const SelectBasicDefinition = createComponentDefinition({
name: "선택상자",
nameEng: "SelectBasic Component",
description: "옵션 선택을 위한 드롭다운 선택상자 컴포넌트",
category: ComponentCategory.INPUT,
category: ComponentCategory.FORM,
webType: "select",
component: SelectBasicWrapper,
defaultConfig: {

View File

@ -17,7 +17,7 @@ export const SliderBasicDefinition = createComponentDefinition({
name: "슬라이더",
nameEng: "SliderBasic Component",
description: "범위 값 선택을 위한 슬라이더 컴포넌트",
category: ComponentCategory.INPUT,
category: ComponentCategory.FORM,
webType: "number",
component: SliderBasicWrapper,
defaultConfig: {

View File

@ -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,
};
```

View File

@ -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} />;
};

View File

@ -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>
);
};

View File

@ -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();
}

View File

@ -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,
},
},
};

View File

@ -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";

View File

@ -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; // 선택 항목 동기화
}

View File

@ -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>

View File

@ -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" />

View File

@ -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;

View File

@ -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 (

View File

@ -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: {

View File

@ -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) {

View File

@ -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 }),

View File

@ -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 || "";
};

View File

@ -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} // 테이블 변경 핸들러 전달
/>
);
};

View File

@ -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),
},
};
}

View File

@ -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[];

View File

@ -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;
}

View File

@ -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; // 인덱스 번호 표시 여부
}
/**
*
*/

685
package-lock.json generated
View File

@ -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",

View File

@ -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",