Compare commits
6 Commits
c5b287a2fe
...
70855c09c6
| Author | SHA1 | Date |
|---|---|---|
|
|
70855c09c6 | |
|
|
aa066a1ea9 | |
|
|
f57a7babe6 | |
|
|
95e68ca087 | |
|
|
1c2249ee42 | |
|
|
9bf879e29d |
|
|
@ -33,7 +33,17 @@ app.use(
|
||||||
origin: config.cors.origin, // 이미 배열 또는 boolean으로 처리됨
|
origin: config.cors.origin, // 이미 배열 또는 boolean으로 처리됨
|
||||||
credentials: config.cors.credentials,
|
credentials: config.cors.credentials,
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
allowedHeaders: [
|
||||||
|
"Content-Type",
|
||||||
|
"Authorization",
|
||||||
|
"X-Requested-With",
|
||||||
|
"Accept",
|
||||||
|
"Origin",
|
||||||
|
"Access-Control-Request-Method",
|
||||||
|
"Access-Control-Request-Headers",
|
||||||
|
],
|
||||||
|
preflightContinue: false,
|
||||||
|
optionsSuccessStatus: 200,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,9 +129,57 @@ export class DynamicFormService {
|
||||||
dataToInsert.updated_by = updated_by;
|
dataToInsert.updated_by = updated_by;
|
||||||
}
|
}
|
||||||
if (company_code && tableColumns.includes("company_code")) {
|
if (company_code && tableColumns.includes("company_code")) {
|
||||||
dataToInsert.company_code = company_code;
|
// company_code가 UUID 형태(36자)라면 하이픈 제거하여 32자로 만듦
|
||||||
|
let processedCompanyCode = company_code;
|
||||||
|
if (
|
||||||
|
typeof company_code === "string" &&
|
||||||
|
company_code.length === 36 &&
|
||||||
|
company_code.includes("-")
|
||||||
|
) {
|
||||||
|
processedCompanyCode = company_code.replace(/-/g, "");
|
||||||
|
console.log(
|
||||||
|
`🔧 company_code 길이 조정: "${company_code}" -> "${processedCompanyCode}" (${processedCompanyCode.length}자)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 여전히 32자를 초과하면 앞의 32자만 사용
|
||||||
|
if (
|
||||||
|
typeof processedCompanyCode === "string" &&
|
||||||
|
processedCompanyCode.length > 32
|
||||||
|
) {
|
||||||
|
processedCompanyCode = processedCompanyCode.substring(0, 32);
|
||||||
|
console.log(
|
||||||
|
`⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dataToInsert.company_code = processedCompanyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜/시간 문자열을 적절한 형태로 변환
|
||||||
|
Object.keys(dataToInsert).forEach((key) => {
|
||||||
|
const value = dataToInsert[key];
|
||||||
|
|
||||||
|
// 날짜/시간 관련 컬럼명 패턴 체크 (regdate, created_at, updated_at 등)
|
||||||
|
if (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(key.toLowerCase().includes("date") ||
|
||||||
|
key.toLowerCase().includes("time") ||
|
||||||
|
key.toLowerCase().includes("created") ||
|
||||||
|
key.toLowerCase().includes("updated") ||
|
||||||
|
key.toLowerCase().includes("reg"))
|
||||||
|
) {
|
||||||
|
// YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환
|
||||||
|
if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||||||
|
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
|
||||||
|
dataToInsert[key] = new Date(value);
|
||||||
|
}
|
||||||
|
// YYYY-MM-DD 형태의 문자열을 Date 객체로 변환
|
||||||
|
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
|
||||||
|
dataToInsert[key] = new Date(value + "T00:00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 존재하지 않는 컬럼 제거
|
// 존재하지 않는 컬럼 제거
|
||||||
Object.keys(dataToInsert).forEach((key) => {
|
Object.keys(dataToInsert).forEach((key) => {
|
||||||
if (!tableColumns.includes(key)) {
|
if (!tableColumns.includes(key)) {
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,22 @@ export class ScreenManagementService {
|
||||||
screenData: CreateScreenRequest,
|
screenData: CreateScreenRequest,
|
||||||
userCompanyCode: string
|
userCompanyCode: string
|
||||||
): Promise<ScreenDefinition> {
|
): Promise<ScreenDefinition> {
|
||||||
|
console.log(`=== 화면 생성 요청 ===`);
|
||||||
|
console.log(`요청 데이터:`, screenData);
|
||||||
|
console.log(`사용자 회사 코드:`, userCompanyCode);
|
||||||
|
|
||||||
// 화면 코드 중복 확인
|
// 화면 코드 중복 확인
|
||||||
const existingScreen = await prisma.screen_definitions.findUnique({
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
where: { screen_code: screenData.screenCode },
|
where: { screen_code: screenData.screenCode },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`화면 코드 '${screenData.screenCode}' 중복 검사 결과:`,
|
||||||
|
existingScreen ? "중복됨" : "사용 가능"
|
||||||
|
);
|
||||||
|
|
||||||
if (existingScreen) {
|
if (existingScreen) {
|
||||||
|
console.log(`기존 화면 정보:`, existingScreen);
|
||||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,6 +447,8 @@ export class ScreenManagementService {
|
||||||
console.log(`=== 레이아웃 저장 시작 ===`);
|
console.log(`=== 레이아웃 저장 시작 ===`);
|
||||||
console.log(`화면 ID: ${screenId}`);
|
console.log(`화면 ID: ${screenId}`);
|
||||||
console.log(`컴포넌트 수: ${layoutData.components.length}`);
|
console.log(`컴포넌트 수: ${layoutData.components.length}`);
|
||||||
|
console.log(`격자 설정:`, layoutData.gridSettings);
|
||||||
|
console.log(`해상도 설정:`, layoutData.screenResolution);
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
const existingScreen = await prisma.screen_definitions.findUnique({
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
|
@ -451,12 +463,37 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 레이아웃 삭제
|
// 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두)
|
||||||
await prisma.screen_layouts.deleteMany({
|
await prisma.screen_layouts.deleteMany({
|
||||||
where: { screen_id: screenId },
|
where: { screen_id: screenId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 새 레이아웃 저장
|
// 1. 메타데이터 저장 (격자 설정과 해상도 정보)
|
||||||
|
if (layoutData.gridSettings || layoutData.screenResolution) {
|
||||||
|
const metadata: any = {
|
||||||
|
gridSettings: layoutData.gridSettings,
|
||||||
|
screenResolution: layoutData.screenResolution,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.screen_layouts.create({
|
||||||
|
data: {
|
||||||
|
screen_id: screenId,
|
||||||
|
component_type: "_metadata", // 특별한 타입으로 메타데이터 식별
|
||||||
|
component_id: `_metadata_${screenId}`,
|
||||||
|
parent_id: null,
|
||||||
|
position_x: 0,
|
||||||
|
position_y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: metadata,
|
||||||
|
display_order: -1, // 메타데이터는 맨 앞에 배치
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`메타데이터 저장 완료:`, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 컴포넌트 저장
|
||||||
for (const component of layoutData.components) {
|
for (const component of layoutData.components) {
|
||||||
const { id, ...componentData } = component;
|
const { id, ...componentData } = component;
|
||||||
|
|
||||||
|
|
@ -531,14 +568,45 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`);
|
console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`);
|
||||||
|
|
||||||
if (layouts.length === 0) {
|
// 메타데이터와 컴포넌트 분리
|
||||||
|
const metadataLayout = layouts.find(
|
||||||
|
(layout) => layout.component_type === "_metadata"
|
||||||
|
);
|
||||||
|
const componentLayouts = layouts.filter(
|
||||||
|
(layout) => layout.component_type !== "_metadata"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 메타데이터 설정
|
||||||
|
let gridSettings = {
|
||||||
|
columns: 12,
|
||||||
|
gap: 16,
|
||||||
|
padding: 16,
|
||||||
|
snapToGrid: true,
|
||||||
|
showGrid: true,
|
||||||
|
};
|
||||||
|
let screenResolution = null;
|
||||||
|
|
||||||
|
// 저장된 메타데이터가 있으면 적용
|
||||||
|
if (metadataLayout && metadataLayout.properties) {
|
||||||
|
const metadata = metadataLayout.properties as any;
|
||||||
|
if (metadata.gridSettings) {
|
||||||
|
gridSettings = { ...gridSettings, ...metadata.gridSettings };
|
||||||
|
}
|
||||||
|
if (metadata.screenResolution) {
|
||||||
|
screenResolution = metadata.screenResolution;
|
||||||
|
}
|
||||||
|
console.log(`메타데이터 로드:`, { gridSettings, screenResolution });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentLayouts.length === 0) {
|
||||||
return {
|
return {
|
||||||
components: [],
|
components: [],
|
||||||
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
gridSettings,
|
||||||
|
screenResolution,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const components: ComponentData[] = layouts.map((layout) => {
|
const components: ComponentData[] = componentLayouts.map((layout) => {
|
||||||
const properties = layout.properties as any;
|
const properties = layout.properties as any;
|
||||||
const component = {
|
const component = {
|
||||||
id: layout.component_id,
|
id: layout.component_id,
|
||||||
|
|
@ -567,10 +635,13 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
console.log(`=== 레이아웃 로드 완료 ===`);
|
console.log(`=== 레이아웃 로드 완료 ===`);
|
||||||
console.log(`반환할 컴포넌트 수: ${components.length}`);
|
console.log(`반환할 컴포넌트 수: ${components.length}`);
|
||||||
|
console.log(`최종 격자 설정:`, gridSettings);
|
||||||
|
console.log(`최종 해상도 설정:`, screenResolution);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
components,
|
components,
|
||||||
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
gridSettings,
|
||||||
|
screenResolution,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export type ComponentData =
|
||||||
export interface LayoutData {
|
export interface LayoutData {
|
||||||
components: ComponentData[];
|
components: ComponentData[];
|
||||||
gridSettings?: GridSettings;
|
gridSettings?: GridSettings;
|
||||||
|
screenResolution?: ScreenResolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 설정
|
// 그리드 설정
|
||||||
|
|
@ -115,6 +116,18 @@ export interface GridSettings {
|
||||||
columns: number; // 기본값: 12
|
columns: number; // 기본값: 12
|
||||||
gap: number; // 기본값: 16px
|
gap: number; // 기본값: 16px
|
||||||
padding: number; // 기본값: 16px
|
padding: number; // 기본값: 16px
|
||||||
|
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
|
||||||
|
showGrid?: boolean; // 격자 표시 여부 (기본값: true)
|
||||||
|
gridColor?: string; // 격자 색상 (기본값: #d1d5db)
|
||||||
|
gridOpacity?: number; // 격자 투명도 (기본값: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 해상도 설정
|
||||||
|
export interface ScreenResolution {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
name: string;
|
||||||
|
category: "desktop" | "tablet" | "mobile" | "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효성 검증 규칙
|
// 유효성 검증 규칙
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
|
- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
|
||||||
- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징
|
- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징
|
||||||
- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅
|
- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅
|
||||||
|
- **🆕 화면 저장 후 메뉴 할당**: 저장 완료 시 자동 메뉴 할당 모달, 기존 화면 교체 확인, 시각적 피드백 및 자동 목록 복귀
|
||||||
|
|
||||||
#### 🔧 해결된 기술적 문제들
|
#### 🔧 해결된 기술적 문제들
|
||||||
|
|
||||||
|
|
@ -410,6 +411,9 @@ const removeItem = useCallback(
|
||||||
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당
|
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당
|
||||||
- **메뉴-화면 연결**: 메뉴와 화면의 1:1 또는 1:N 연결
|
- **메뉴-화면 연결**: 메뉴와 화면의 1:1 또는 1:N 연결
|
||||||
- **권한 기반 메뉴 표시**: 사용자 권한에 따른 메뉴 표시 제어
|
- **권한 기반 메뉴 표시**: 사용자 권한에 따른 메뉴 표시 제어
|
||||||
|
- **🆕 저장 후 자동 할당**: 화면 저장 완료 시 메뉴 할당 모달 자동 팝업
|
||||||
|
- **🆕 기존 화면 교체**: 이미 할당된 화면이 있을 때 교체 확인 및 안전한 처리
|
||||||
|
- **🆕 완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름
|
||||||
|
|
||||||
## 🗄️ 데이터베이스 설계
|
## 🗄️ 데이터베이스 설계
|
||||||
|
|
||||||
|
|
@ -1172,9 +1176,264 @@ function generateValidationRules(column: ColumnInfo): ValidationRule[] {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🎯 메뉴 할당 시스템 (신규 완성)
|
||||||
|
|
||||||
|
### 1. 화면 저장 후 메뉴 할당 워크플로우
|
||||||
|
|
||||||
|
#### 전체 프로세스
|
||||||
|
|
||||||
|
```
|
||||||
|
화면 설계 완료 → 저장 버튼 클릭 → 메뉴 할당 모달 자동 팝업
|
||||||
|
↓
|
||||||
|
메뉴 선택 및 할당 OR "나중에 할당" 클릭
|
||||||
|
↓
|
||||||
|
성공 화면 표시 (3초간 시각적 피드백)
|
||||||
|
↓
|
||||||
|
자동으로 화면 목록 페이지로 이동
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 메뉴 할당 모달 (MenuAssignmentModal)
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
|
||||||
|
1. **관리자 메뉴만 표시**: 화면관리는 관리자 전용 기능이므로 관리자 메뉴(`menuType: "0"`)만 로드
|
||||||
|
2. **셀렉트박스 내부 검색**: 메뉴명, URL, 설명으로 실시간 검색 가능
|
||||||
|
3. **기존 화면 감지**: 선택한 메뉴에 이미 할당된 화면이 있는지 자동 확인
|
||||||
|
4. **화면 교체 확인**: 기존 화면이 있을 때 교체 확인 대화상자 표시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MenuAssignmentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
screenInfo: ScreenDefinition | null;
|
||||||
|
onAssignmentComplete?: () => void;
|
||||||
|
onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 메뉴 검색 시스템
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 셀렉트박스 내부 검색 구현
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
{/* 검색 입력 필드 */}
|
||||||
|
<div className="sticky top-0 z-10 border-b bg-white p-2">
|
||||||
|
<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="메뉴명, URL, 설명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation(); // 이벤트 전파 방지
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-8 pr-8 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button onClick={() => setSearchTerm("")}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 메뉴 옵션들 */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 기존 화면 감지 및 교체 시스템
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 메뉴 선택 시 기존 할당된 화면 확인
|
||||||
|
const handleMenuSelect = async (menuId: string) => {
|
||||||
|
const menu = menus.find((m) => m.objid?.toString() === menuId);
|
||||||
|
setSelectedMenu(menu || null);
|
||||||
|
|
||||||
|
if (menu) {
|
||||||
|
try {
|
||||||
|
const menuObjid = parseInt(menu.objid?.toString() || "0");
|
||||||
|
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||||
|
setExistingScreens(screens);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("할당된 화면 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 할당 시 기존 화면 확인
|
||||||
|
const handleAssignScreen = async () => {
|
||||||
|
if (existingScreens.length > 0) {
|
||||||
|
// 이미 같은 화면이 할당되어 있는지 확인
|
||||||
|
const alreadyAssigned = existingScreens.some(
|
||||||
|
(screen) => screen.screenId === screenInfo.screenId
|
||||||
|
);
|
||||||
|
if (alreadyAssigned) {
|
||||||
|
toast.info("이미 해당 메뉴에 할당된 화면입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 화면이 할당되어 있으면 교체 확인
|
||||||
|
setShowReplaceDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 화면이 없으면 바로 할당
|
||||||
|
await performAssignment();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 화면 교체 확인 대화상자
|
||||||
|
|
||||||
|
**시각적 구분:**
|
||||||
|
|
||||||
|
- 🔴 **제거될 화면**: 빨간색 배경으로 표시
|
||||||
|
- 🟢 **새로 할당될 화면**: 초록색 배경으로 표시
|
||||||
|
- 🟠 **주의 메시지**: 작업이 되돌릴 수 없음을 명확히 안내
|
||||||
|
|
||||||
|
**안전한 교체 프로세스:**
|
||||||
|
|
||||||
|
1. 기존 화면들을 하나씩 제거
|
||||||
|
2. 새 화면 할당
|
||||||
|
3. 성공/실패 로그 출력
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
|
||||||
|
if (replaceExisting && existingScreens.length > 0) {
|
||||||
|
for (const existingScreen of existingScreens) {
|
||||||
|
try {
|
||||||
|
await menuScreenApi.unassignScreenFromMenu(
|
||||||
|
existingScreen.screenId,
|
||||||
|
menuObjid
|
||||||
|
);
|
||||||
|
console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`기존 화면 "${existingScreen.screenName}" 제거 실패:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 화면 할당
|
||||||
|
await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 성공 피드백 및 자동 이동
|
||||||
|
|
||||||
|
**성공 화면 구성:**
|
||||||
|
|
||||||
|
- ✅ **체크마크 아이콘**: 성공을 나타내는 녹색 체크마크
|
||||||
|
- 🎯 **성공 메시지**: 구체적인 할당 완료 메시지
|
||||||
|
- ⏱️ **자동 이동 안내**: "3초 후 자동으로 화면 목록으로 이동합니다..."
|
||||||
|
- 🔵 **로딩 애니메이션**: 3개의 점이 순차적으로 바운스하는 애니메이션
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 성공 상태 설정
|
||||||
|
setAssignmentSuccess(true);
|
||||||
|
setAssignmentMessage(successMessage);
|
||||||
|
|
||||||
|
// 3초 후 자동으로 화면 목록으로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onBackToList) {
|
||||||
|
onBackToList();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
**성공 화면 UI:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{assignmentSuccess ? (
|
||||||
|
// 성공 화면
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
화면 할당 완료
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border bg-green-50 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<Monitor className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-900">{assignmentMessage}</p>
|
||||||
|
<p className="mt-1 text-xs text-green-700">
|
||||||
|
3초 후 자동으로 화면 목록으로 이동합니다...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로딩 애니메이션 */}
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.3s]"></div>
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.15s]"></div>
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 기본 할당 화면
|
||||||
|
// ...
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 사용자 경험 개선사항
|
||||||
|
|
||||||
|
1. **선택적 할당**: 필수가 아닌 선택적 기능으로 "나중에 할당" 가능
|
||||||
|
2. **직관적 UI**: 저장된 화면 정보를 모달에서 바로 확인 가능
|
||||||
|
3. **검색 기능**: 많은 메뉴 중에서 쉽게 찾을 수 있음
|
||||||
|
4. **상태 표시**: 메뉴 활성/비활성 상태, 기존 할당된 화면 정보 표시
|
||||||
|
5. **완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름
|
||||||
|
|
||||||
## 🌐 API 설계
|
## 🌐 API 설계
|
||||||
|
|
||||||
### 1. 화면 정의 API
|
### 1. 메뉴-화면 할당 API
|
||||||
|
|
||||||
|
#### 화면을 메뉴에 할당
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
POST /screen-management/screens/:screenId/assign-menu
|
||||||
|
Request: {
|
||||||
|
menuObjid: number;
|
||||||
|
displayOrder?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 메뉴별 할당된 화면 목록 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
GET /screen-management/menus/:menuObjid/screens
|
||||||
|
Response: {
|
||||||
|
success: boolean;
|
||||||
|
data: ScreenDefinition[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 화면-메뉴 할당 해제
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
DELETE /screen-management/screens/:screenId/menus/:menuObjid
|
||||||
|
Response: {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 화면 정의 API
|
||||||
|
|
||||||
#### 화면 목록 조회 (회사별)
|
#### 화면 목록 조회 (회사별)
|
||||||
|
|
||||||
|
|
@ -2675,7 +2934,19 @@ export class TableTypeIntegrationService {
|
||||||
3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정
|
3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정
|
||||||
4. **저장**: 커스터마이징된 화면 저장
|
4. **저장**: 커스터마이징된 화면 저장
|
||||||
|
|
||||||
### 6. 메뉴 할당 및 관리
|
### 6. 메뉴 할당 및 관리 (신규 완성)
|
||||||
|
|
||||||
|
#### 🆕 저장 후 자동 메뉴 할당
|
||||||
|
|
||||||
|
1. **화면 저장 완료**: 화면 설계 완료 후 저장 버튼 클릭
|
||||||
|
2. **메뉴 할당 모달 자동 팝업**: 저장 성공 시 즉시 메뉴 할당 모달 표시
|
||||||
|
3. **관리자 메뉴 검색**: 메뉴명, URL, 설명으로 실시간 검색
|
||||||
|
4. **기존 화면 확인**: 선택한 메뉴에 이미 할당된 화면 자동 감지
|
||||||
|
5. **교체 확인**: 기존 화면이 있을 때 교체 여부 확인 대화상자
|
||||||
|
6. **안전한 교체**: 기존 화면 제거 후 새 화면 할당
|
||||||
|
7. **성공 피드백**: 3초간 성공 화면 표시 후 자동으로 화면 목록으로 이동
|
||||||
|
|
||||||
|
#### 기존 메뉴 할당 방식
|
||||||
|
|
||||||
1. **메뉴 선택**: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
|
1. **메뉴 선택**: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
|
||||||
2. **화면 할당**: 선택한 화면을 메뉴에 할당
|
2. **화면 할당**: 선택한 화면을 메뉴에 할당
|
||||||
|
|
@ -2778,6 +3049,7 @@ export class TableTypeIntegrationService {
|
||||||
- [x] 메뉴-화면 할당 기능 구현
|
- [x] 메뉴-화면 할당 기능 구현
|
||||||
- [x] 인터랙티브 화면 뷰어 구현
|
- [x] 인터랙티브 화면 뷰어 구현
|
||||||
- [x] 사용자 피드백 반영 완료
|
- [x] 사용자 피드백 반영 완료
|
||||||
|
- [x] 🆕 화면 저장 후 메뉴 할당 워크플로우 구현
|
||||||
|
|
||||||
**구현 완료 사항:**
|
**구현 완료 사항:**
|
||||||
|
|
||||||
|
|
@ -2788,6 +3060,7 @@ export class TableTypeIntegrationService {
|
||||||
- 메뉴 관리에서 화면 할당 기능 구현
|
- 메뉴 관리에서 화면 할당 기능 구현
|
||||||
- 할당된 화면을 실제 사용 가능한 인터랙티브 화면으로 렌더링
|
- 할당된 화면을 실제 사용 가능한 인터랙티브 화면으로 렌더링
|
||||||
- 실제 사용자 입력 및 상호작용 가능한 완전 기능 화면 구현
|
- 실제 사용자 입력 및 상호작용 가능한 완전 기능 화면 구현
|
||||||
|
- 🆕 **완전한 메뉴 할당 워크플로우**: 저장 → 메뉴 할당 모달 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀
|
||||||
|
|
||||||
## 🎯 현재 구현된 핵심 기능
|
## 🎯 현재 구현된 핵심 기능
|
||||||
|
|
||||||
|
|
@ -3559,3 +3832,4 @@ ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로
|
||||||
- ✅ **메뉴 연동**: 설계한 화면을 실제 메뉴에 할당하여 즉시 사용
|
- ✅ **메뉴 연동**: 설계한 화면을 실제 메뉴에 할당하여 즉시 사용
|
||||||
- ✅ **인터랙티브 화면**: 할당된 화면에서 실제 사용자 입력 및 상호작용 가능
|
- ✅ **인터랙티브 화면**: 할당된 화면에서 실제 사용자 입력 및 상호작용 가능
|
||||||
- ✅ **13가지 웹 타입 지원**: 모든 업무 요구사항에 대응 가능한 다양한 위젯
|
- ✅ **13가지 웹 타입 지원**: 모든 업무 요구사항에 대응 가능한 다양한 위젯
|
||||||
|
- ✅ **🆕 완전한 메뉴 할당 워크플로우**: 저장 → 자동 메뉴 할당 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀의 완벽한 사용자 경험
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-white">
|
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
||||||
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
||||||
|
|
@ -69,7 +69,7 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (error || !screen) {
|
if (error || !screen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-white">
|
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||||
<span className="text-2xl">⚠️</span>
|
<span className="text-2xl">⚠️</span>
|
||||||
|
|
@ -84,11 +84,23 @@ export default function ScreenViewPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
||||||
|
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||||
|
const screenHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-white">
|
<div className="h-full w-full overflow-auto bg-white">
|
||||||
{layout && layout.components.length > 0 ? (
|
{layout && layout.components.length > 0 ? (
|
||||||
// 캔버스 컴포넌트들만 표시 - 전체 화면 사용
|
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||||
<div className="relative h-full w-full">
|
<div
|
||||||
|
className="relative mx-auto bg-white"
|
||||||
|
style={{
|
||||||
|
width: `${screenWidth}px`,
|
||||||
|
height: `${screenHeight}px`,
|
||||||
|
minWidth: `${screenWidth}px`,
|
||||||
|
minHeight: `${screenHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{layout.components
|
{layout.components
|
||||||
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
|
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
|
||||||
.map((component) => {
|
.map((component) => {
|
||||||
|
|
@ -218,7 +230,15 @@ export default function ScreenViewPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면일 때도 깔끔하게 표시
|
// 빈 화면일 때도 깔끔하게 표시
|
||||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
<div
|
||||||
|
className="mx-auto flex items-center justify-center bg-gray-50"
|
||||||
|
style={{
|
||||||
|
width: `${screenWidth}px`,
|
||||||
|
height: `${screenHeight}px`,
|
||||||
|
minWidth: `${screenWidth}px`,
|
||||||
|
minHeight: `${screenHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<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">
|
<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>
|
<span className="text-2xl">📄</span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
|
|
@ -194,6 +194,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
||||||
export function AppLayout({ children }: AppLayoutProps) {
|
export function AppLayout({ children }: AppLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { user, logout, refreshUserData } = useAuth();
|
const { user, logout, refreshUserData } = useAuth();
|
||||||
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
@ -216,8 +217,8 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||||
saveProfile,
|
saveProfile,
|
||||||
} = useProfile(user, refreshUserData, refreshMenus);
|
} = useProfile(user, refreshUserData, refreshMenus);
|
||||||
|
|
||||||
// 현재 경로에 따라 어드민 모드인지 판단
|
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
||||||
const isAdminMode = pathname.startsWith("/admin");
|
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||||
|
|
||||||
// 현재 모드에 따라 표시할 메뉴 결정
|
// 현재 모드에 따라 표시할 메뉴 결정
|
||||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
||||||
|
|
@ -246,7 +247,20 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||||
if (assignedScreens.length > 0) {
|
if (assignedScreens.length > 0) {
|
||||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
||||||
const firstScreen = assignedScreens[0];
|
const firstScreen = assignedScreens[0];
|
||||||
router.push(`/screens/${firstScreen.screenId}`);
|
|
||||||
|
// 관리자 모드 상태를 쿼리 파라미터로 전달
|
||||||
|
const screenPath = isAdminMode
|
||||||
|
? `/screens/${firstScreen.screenId}?mode=admin`
|
||||||
|
: `/screens/${firstScreen.screenId}`;
|
||||||
|
|
||||||
|
console.log("🎯 메뉴에서 화면으로 이동:", {
|
||||||
|
menuName: menu.name,
|
||||||
|
screenId: firstScreen.screenId,
|
||||||
|
isAdminMode,
|
||||||
|
targetPath: screenPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(screenPath);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Cog,
|
Cog,
|
||||||
Layout,
|
Layout,
|
||||||
|
Monitor,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -152,6 +153,19 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
D
|
D
|
||||||
</Badge>
|
</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={panelStates.resolution?.isOpen ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onTogglePanel("resolution")}
|
||||||
|
className={cn("flex items-center space-x-2", panelStates.resolution?.isOpen && "bg-blue-600 text-white")}
|
||||||
|
>
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
<span>해상도</span>
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs">
|
||||||
|
E
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 액션 버튼들 */}
|
{/* 우측: 액션 버튼들 */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,9 +8,11 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ko } from "date-fns/locale";
|
import { ko } from "date-fns/locale";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
|
|
@ -30,6 +32,7 @@ import {
|
||||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -51,26 +54,173 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
screenInfo,
|
screenInfo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기
|
||||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||||
|
|
||||||
|
// 팝업 화면 상태
|
||||||
|
const [popupScreen, setPopupScreen] = useState<{
|
||||||
|
screenId: number;
|
||||||
|
title: string;
|
||||||
|
size: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 팝업 화면 레이아웃 상태
|
||||||
|
const [popupLayout, setPopupLayout] = useState<ComponentData[]>([]);
|
||||||
|
const [popupLoading, setPopupLoading] = useState(false);
|
||||||
|
const [popupScreenResolution, setPopupScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
const [popupScreenInfo, setPopupScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||||
|
|
||||||
|
// 팝업 전용 formData 상태
|
||||||
|
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 실제 사용할 폼 데이터 (외부에서 제공된 경우 우선 사용)
|
// 자동값 생성 함수
|
||||||
const formData = externalFormData || localFormData;
|
const generateAutoValue = useCallback((autoValueType: string): string => {
|
||||||
|
const now = new Date();
|
||||||
|
switch (autoValueType) {
|
||||||
|
case "current_datetime":
|
||||||
|
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
||||||
|
case "current_date":
|
||||||
|
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
case "current_time":
|
||||||
|
return now.toTimeString().slice(0, 8); // HH:mm:ss
|
||||||
|
case "current_user":
|
||||||
|
// 실제 접속중인 사용자명 사용
|
||||||
|
return userName || "사용자"; // 사용자명이 없으면 기본값
|
||||||
|
case "uuid":
|
||||||
|
return crypto.randomUUID();
|
||||||
|
case "sequence":
|
||||||
|
return `SEQ_${Date.now()}`;
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, [userName]); // userName 의존성 추가
|
||||||
|
|
||||||
|
// 팝업 화면 레이아웃 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (popupScreen) {
|
||||||
|
const loadPopupLayout = async () => {
|
||||||
|
try {
|
||||||
|
setPopupLoading(true);
|
||||||
|
console.log("🔍 팝업 화면 로드 시작:", popupScreen);
|
||||||
|
|
||||||
|
// 화면 레이아웃과 화면 정보를 병렬로 가져오기
|
||||||
|
const [layout, screen] = await Promise.all([
|
||||||
|
screenApi.getLayout(popupScreen.screenId),
|
||||||
|
screenApi.getScreen(popupScreen.screenId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("📊 팝업 화면 로드 완료:", {
|
||||||
|
componentsCount: layout.components?.length || 0,
|
||||||
|
screenInfo: {
|
||||||
|
screenId: screen.screenId,
|
||||||
|
tableName: screen.tableName
|
||||||
|
},
|
||||||
|
popupFormData: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
setPopupLayout(layout.components || []);
|
||||||
|
setPopupScreenResolution(layout.screenResolution || null);
|
||||||
|
setPopupScreenInfo({
|
||||||
|
id: popupScreen.screenId,
|
||||||
|
tableName: screen.tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
// 팝업 formData 초기화
|
||||||
|
setPopupFormData({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 팝업 화면 로드 실패:", error);
|
||||||
|
setPopupLayout([]);
|
||||||
|
setPopupScreenInfo(null);
|
||||||
|
} finally {
|
||||||
|
setPopupLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPopupLayout();
|
||||||
|
}
|
||||||
|
}, [popupScreen]);
|
||||||
|
|
||||||
|
// 실제 사용할 폼 데이터 (외부와 로컬 데이터 병합)
|
||||||
|
const formData = { ...localFormData, ...externalFormData };
|
||||||
|
console.log("🔄 formData 구성:", {
|
||||||
|
external: externalFormData,
|
||||||
|
local: localFormData,
|
||||||
|
merged: formData,
|
||||||
|
hasExternalCallback: !!onFormDataChange
|
||||||
|
});
|
||||||
|
|
||||||
// 폼 데이터 업데이트
|
// 폼 데이터 업데이트
|
||||||
const updateFormData = (fieldName: string, value: any) => {
|
const updateFormData = (fieldName: string, value: any) => {
|
||||||
|
console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`);
|
||||||
|
|
||||||
|
// 항상 로컬 상태도 업데이트
|
||||||
|
setLocalFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}));
|
||||||
|
console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
||||||
|
|
||||||
|
// 외부 콜백이 있는 경우에도 전달
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
// 외부 콜백이 있는 경우 사용
|
// 개별 필드를 객체로 변환해서 전달
|
||||||
onFormDataChange(fieldName, value);
|
const dataToSend = { [fieldName]: value };
|
||||||
} else {
|
onFormDataChange(dataToSend);
|
||||||
// 로컬 상태 업데이트
|
console.log(`📤 외부 콜백으로 전달: ${fieldName} = "${value}" (객체: ${JSON.stringify(dataToSend)})`);
|
||||||
setLocalFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 자동입력 필드들의 값을 formData에 초기 설정
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
|
||||||
|
const initAutoInputFields = () => {
|
||||||
|
console.log("🔧 initAutoInputFields 실행 시작");
|
||||||
|
allComponents.forEach(comp => {
|
||||||
|
if (comp.type === 'widget') {
|
||||||
|
const widget = comp as WidgetComponent;
|
||||||
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
|
||||||
|
// 텍스트 타입 위젯의 자동입력 처리
|
||||||
|
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
|
||||||
|
widget.webTypeConfig) {
|
||||||
|
const config = widget.webTypeConfig as TextTypeConfig;
|
||||||
|
const isAutoInput = config?.autoInput || false;
|
||||||
|
|
||||||
|
if (isAutoInput && config?.autoValueType) {
|
||||||
|
// 이미 값이 있으면 덮어쓰지 않음
|
||||||
|
const currentValue = formData[fieldName];
|
||||||
|
console.log(`🔍 자동입력 필드 체크: ${fieldName}`, {
|
||||||
|
currentValue,
|
||||||
|
isEmpty: currentValue === undefined || currentValue === '',
|
||||||
|
isAutoInput,
|
||||||
|
autoValueType: config.autoValueType
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentValue === undefined || currentValue === '') {
|
||||||
|
const autoValue = config.autoValueType === "custom"
|
||||||
|
? config.customValue || ""
|
||||||
|
: generateAutoValue(config.autoValueType);
|
||||||
|
|
||||||
|
console.log("🔄 자동입력 필드 초기화:", {
|
||||||
|
fieldName,
|
||||||
|
autoValueType: config.autoValueType,
|
||||||
|
autoValue
|
||||||
|
});
|
||||||
|
|
||||||
|
updateFormData(fieldName, autoValue);
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 로드 시 자동입력 필드들 설정
|
||||||
|
initAutoInputFields();
|
||||||
|
}, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지)
|
||||||
|
|
||||||
// 날짜 값 업데이트
|
// 날짜 값 업데이트
|
||||||
const updateDateValue = (fieldName: string, date: Date | undefined) => {
|
const updateDateValue = (fieldName: string, date: Date | undefined) => {
|
||||||
setDateValues((prev) => ({
|
setDateValues((prev) => ({
|
||||||
|
|
@ -125,6 +275,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const config = widget.webTypeConfig as TextTypeConfig | undefined;
|
const config = widget.webTypeConfig as TextTypeConfig | undefined;
|
||||||
|
|
||||||
|
// 자동입력 관련 처리
|
||||||
|
const isAutoInput = config?.autoInput || false;
|
||||||
|
const autoValue = isAutoInput && config?.autoValueType
|
||||||
|
? config.autoValueType === "custom"
|
||||||
|
? config.customValue || ""
|
||||||
|
: generateAutoValue(config.autoValueType)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 기본값 또는 자동값 설정
|
||||||
|
const displayValue = isAutoInput ? autoValue : currentValue || config?.defaultValue || "";
|
||||||
|
|
||||||
console.log("📝 InteractiveScreenViewer - Text 위젯:", {
|
console.log("📝 InteractiveScreenViewer - Text 위젯:", {
|
||||||
componentId: widget.id,
|
componentId: widget.id,
|
||||||
widgetType: widget.widgetType,
|
widgetType: widget.widgetType,
|
||||||
|
|
@ -135,6 +296,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
maxLength: config?.maxLength,
|
maxLength: config?.maxLength,
|
||||||
pattern: config?.pattern,
|
pattern: config?.pattern,
|
||||||
placeholder: config?.placeholder,
|
placeholder: config?.placeholder,
|
||||||
|
defaultValue: config?.defaultValue,
|
||||||
|
autoInput: isAutoInput,
|
||||||
|
autoValueType: config?.autoValueType,
|
||||||
|
autoValue,
|
||||||
|
displayValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -163,6 +329,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 입력 검증 함수
|
// 입력 검증 함수
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
|
console.log(`📝 입력 변경: ${fieldName} = "${value}"`);
|
||||||
|
|
||||||
// 형식별 실시간 검증
|
// 형식별 실시간 검증
|
||||||
if (config?.format && config.format !== "none") {
|
if (config?.format && config.format !== "none") {
|
||||||
|
|
@ -170,6 +337,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
const regex = new RegExp(`^${pattern}$`);
|
const regex = new RegExp(`^${pattern}$`);
|
||||||
if (value && !regex.test(value)) {
|
if (value && !regex.test(value)) {
|
||||||
|
console.log(`❌ 형식 검증 실패: ${fieldName} = "${value}"`);
|
||||||
return; // 유효하지 않은 입력 차단
|
return; // 유효하지 않은 입력 차단
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,9 +345,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 길이 제한 검증
|
// 길이 제한 검증
|
||||||
if (config?.maxLength && value.length > config.maxLength) {
|
if (config?.maxLength && value.length > config.maxLength) {
|
||||||
|
console.log(`❌ 길이 제한 초과: ${fieldName} = "${value}" (최대: ${config.maxLength})`);
|
||||||
return; // 최대 길이 초과 차단
|
return; // 최대 길이 초과 차단
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`✅ updateFormData 호출: ${fieldName} = "${value}"`);
|
||||||
updateFormData(fieldName, value);
|
updateFormData(fieldName, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -189,15 +359,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
return applyStyles(
|
return applyStyles(
|
||||||
<Input
|
<Input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
placeholder={finalPlaceholder}
|
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
|
||||||
value={currentValue}
|
value={displayValue}
|
||||||
onChange={handleInputChange}
|
onChange={isAutoInput ? undefined : handleInputChange}
|
||||||
disabled={readonly}
|
disabled={readonly || isAutoInput}
|
||||||
|
readOnly={isAutoInput}
|
||||||
required={required}
|
required={required}
|
||||||
minLength={config?.minLength}
|
minLength={config?.minLength}
|
||||||
maxLength={config?.maxLength}
|
maxLength={config?.maxLength}
|
||||||
pattern={getPatternByFormat(config?.format || "none")}
|
pattern={getPatternByFormat(config?.format || "none")}
|
||||||
className="w-full"
|
className={`w-full ${isAutoInput ? "bg-gray-50 text-gray-700" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: "100%",
|
minHeight: "100%",
|
||||||
|
|
@ -698,9 +869,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
case "save":
|
case "save":
|
||||||
await handleSaveAction();
|
await handleSaveAction();
|
||||||
break;
|
break;
|
||||||
case "cancel":
|
|
||||||
handleCancelAction();
|
|
||||||
break;
|
|
||||||
case "delete":
|
case "delete":
|
||||||
await handleDeleteAction();
|
await handleDeleteAction();
|
||||||
break;
|
break;
|
||||||
|
|
@ -742,8 +910,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 저장 액션
|
// 저장 액션
|
||||||
const handleSaveAction = async () => {
|
const handleSaveAction = async () => {
|
||||||
if (!formData || Object.keys(formData).length === 0) {
|
// 저장 시점에서 최신 formData 구성
|
||||||
alert("저장할 데이터가 없습니다.");
|
const currentFormData = { ...localFormData, ...externalFormData };
|
||||||
|
console.log("💾 저장 시작 - currentFormData:", currentFormData);
|
||||||
|
console.log("💾 저장 시점 formData 상세:", {
|
||||||
|
local: localFormData,
|
||||||
|
external: externalFormData,
|
||||||
|
merged: currentFormData
|
||||||
|
});
|
||||||
|
console.log("💾 currentFormData 키-값 상세:");
|
||||||
|
Object.entries(currentFormData).forEach(([key, value]) => {
|
||||||
|
console.log(` ${key}: "${value}" (타입: ${typeof value})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
|
||||||
|
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
|
||||||
|
if (!hasWidgets) {
|
||||||
|
alert("저장할 입력 컴포넌트가 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -751,7 +934,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
|
const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id));
|
||||||
const missingFields = requiredFields.filter(field => {
|
const missingFields = requiredFields.filter(field => {
|
||||||
const fieldName = field.columnName || field.id;
|
const fieldName = field.columnName || field.id;
|
||||||
const value = formData[fieldName];
|
const value = currentFormData[fieldName];
|
||||||
return !value || value.toString().trim() === "";
|
return !value || value.toString().trim() === "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -770,27 +953,93 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 컬럼명 기반으로 데이터 매핑
|
// 컬럼명 기반으로 데이터 매핑
|
||||||
const mappedData: Record<string, any> = {};
|
const mappedData: Record<string, any> = {};
|
||||||
|
|
||||||
// 컴포넌트에서 컬럼명이 있는 것들만 매핑
|
// 입력 가능한 컴포넌트에서 데이터 수집
|
||||||
allComponents.forEach(comp => {
|
allComponents.forEach(comp => {
|
||||||
if (comp.columnName) {
|
// 위젯 컴포넌트이고 입력 가능한 타입인 경우
|
||||||
const fieldName = comp.columnName;
|
if (comp.type === 'widget') {
|
||||||
const componentId = comp.id;
|
const widget = comp as WidgetComponent;
|
||||||
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
let value = currentFormData[fieldName];
|
||||||
|
|
||||||
// formData에서 해당 값 찾기 (컬럼명 우선, 없으면 컴포넌트 ID)
|
console.log(`🔍 컴포넌트 처리: ${fieldName}`, {
|
||||||
const value = formData[fieldName] || formData[componentId];
|
widgetType: widget.widgetType,
|
||||||
|
formDataValue: value,
|
||||||
|
hasWebTypeConfig: !!widget.webTypeConfig,
|
||||||
|
config: widget.webTypeConfig
|
||||||
|
});
|
||||||
|
|
||||||
if (value !== undefined && value !== "") {
|
// 자동입력 필드인 경우에만 값이 없을 때 생성
|
||||||
mappedData[fieldName] = value;
|
if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') &&
|
||||||
|
widget.webTypeConfig) {
|
||||||
|
const config = widget.webTypeConfig as TextTypeConfig;
|
||||||
|
const isAutoInput = config?.autoInput || false;
|
||||||
|
|
||||||
|
console.log(`📋 ${fieldName} 자동입력 체크:`, {
|
||||||
|
isAutoInput,
|
||||||
|
autoValueType: config?.autoValueType,
|
||||||
|
hasValue: !!value,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAutoInput && config?.autoValueType && (!value || value === '')) {
|
||||||
|
// 자동입력이고 값이 없을 때만 생성
|
||||||
|
value = config.autoValueType === "custom"
|
||||||
|
? config.customValue || ""
|
||||||
|
: generateAutoValue(config.autoValueType);
|
||||||
|
|
||||||
|
console.log("💾 자동입력 값 저장 (값이 없어서 생성):", {
|
||||||
|
fieldName,
|
||||||
|
autoValueType: config.autoValueType,
|
||||||
|
generatedValue: value
|
||||||
|
});
|
||||||
|
} else if (isAutoInput && value) {
|
||||||
|
console.log("💾 자동입력 필드지만 기존 값 유지:", {
|
||||||
|
fieldName,
|
||||||
|
existingValue: value
|
||||||
|
});
|
||||||
|
} else if (!isAutoInput) {
|
||||||
|
console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값이 있는 경우만 매핑 (빈 문자열도 포함하되, undefined는 제외)
|
||||||
|
if (value !== undefined && value !== null && value !== "undefined") {
|
||||||
|
// columnName이 있으면 columnName을 키로, 없으면 컴포넌트 ID를 키로 사용
|
||||||
|
const saveKey = widget.columnName || `comp_${widget.id}`;
|
||||||
|
mappedData[saveKey] = value;
|
||||||
|
} else if (widget.columnName) {
|
||||||
|
// 값이 없지만 columnName이 있는 경우, 빈 문자열로 저장
|
||||||
|
console.log(`⚠️ ${widget.columnName} 필드에 값이 없어 빈 문자열로 저장`);
|
||||||
|
mappedData[widget.columnName] = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("💾 저장할 데이터 매핑:", {
|
console.log("💾 저장할 데이터 매핑:", {
|
||||||
원본데이터: formData,
|
원본데이터: currentFormData,
|
||||||
매핑된데이터: mappedData,
|
매핑된데이터: mappedData,
|
||||||
화면정보: screenInfo,
|
화면정보: screenInfo,
|
||||||
|
전체컴포넌트수: allComponents.length,
|
||||||
|
위젯컴포넌트수: allComponents.filter(c => c.type === 'widget').length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 각 컴포넌트의 상세 정보 로그
|
||||||
|
console.log("🔍 컴포넌트별 데이터 수집 상세:");
|
||||||
|
allComponents.forEach(comp => {
|
||||||
|
if (comp.type === 'widget') {
|
||||||
|
const widget = comp as WidgetComponent;
|
||||||
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
const value = currentFormData[fieldName];
|
||||||
|
const hasValue = value !== undefined && value !== null && value !== '';
|
||||||
|
console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑된 데이터가 비어있으면 경고
|
||||||
|
if (Object.keys(mappedData).length === 0) {
|
||||||
|
console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다.");
|
||||||
|
}
|
||||||
|
|
||||||
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
|
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
|
||||||
const tableName = screenInfo.tableName ||
|
const tableName = screenInfo.tableName ||
|
||||||
allComponents.find(c => c.columnName)?.tableName ||
|
allComponents.find(c => c.columnName)?.tableName ||
|
||||||
|
|
@ -812,9 +1061,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 저장 후 데이터 초기화 (선택사항)
|
// 저장 후 데이터 초기화 (선택사항)
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
|
const resetData: Record<string, any> = {};
|
||||||
Object.keys(formData).forEach(key => {
|
Object.keys(formData).forEach(key => {
|
||||||
onFormDataChange(key, "");
|
resetData[key] = "";
|
||||||
});
|
});
|
||||||
|
onFormDataChange(resetData);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || "저장에 실패했습니다.");
|
throw new Error(result.message || "저장에 실패했습니다.");
|
||||||
|
|
@ -825,19 +1076,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 취소 액션
|
|
||||||
const handleCancelAction = () => {
|
|
||||||
if (confirm("변경사항을 취소하시겠습니까?")) {
|
|
||||||
// 폼 초기화 또는 이전 페이지로 이동
|
|
||||||
if (onFormDataChange) {
|
|
||||||
// 모든 폼 데이터 초기화
|
|
||||||
Object.keys(formData).forEach(key => {
|
|
||||||
onFormDataChange(key, "");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log("❌ 작업이 취소되었습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 삭제 액션
|
// 삭제 액션
|
||||||
const handleDeleteAction = async () => {
|
const handleDeleteAction = async () => {
|
||||||
|
|
@ -876,9 +1114,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 삭제 후 폼 초기화
|
// 삭제 후 폼 초기화
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
|
const resetData: Record<string, any> = {};
|
||||||
Object.keys(formData).forEach(key => {
|
Object.keys(formData).forEach(key => {
|
||||||
onFormDataChange(key, "");
|
resetData[key] = "";
|
||||||
});
|
});
|
||||||
|
onFormDataChange(resetData);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || "삭제에 실패했습니다.");
|
throw new Error(result.message || "삭제에 실패했습니다.");
|
||||||
|
|
@ -919,9 +1159,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const handleResetAction = () => {
|
const handleResetAction = () => {
|
||||||
if (confirm("모든 입력을 초기화하시겠습니까?")) {
|
if (confirm("모든 입력을 초기화하시겠습니까?")) {
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
|
const resetData: Record<string, any> = {};
|
||||||
Object.keys(formData).forEach(key => {
|
Object.keys(formData).forEach(key => {
|
||||||
onFormDataChange(key, "");
|
resetData[key] = "";
|
||||||
});
|
});
|
||||||
|
onFormDataChange(resetData);
|
||||||
}
|
}
|
||||||
console.log("🔄 폼 초기화 완료");
|
console.log("🔄 폼 초기화 완료");
|
||||||
alert("입력이 초기화되었습니다.");
|
alert("입력이 초기화되었습니다.");
|
||||||
|
|
@ -937,35 +1179,92 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 닫기 액션
|
// 닫기 액션
|
||||||
const handleCloseAction = () => {
|
const handleCloseAction = () => {
|
||||||
console.log("❌ 창 닫기");
|
console.log("❌ 닫기 액션 실행");
|
||||||
// 창 닫기 또는 모달 닫기
|
|
||||||
if (window.opener) {
|
// 모달 내부에서 실행되는지 확인
|
||||||
|
const isInModal = document.querySelector('[role="dialog"]') !== null;
|
||||||
|
const isInPopup = window.opener !== null;
|
||||||
|
|
||||||
|
if (isInModal) {
|
||||||
|
// 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생
|
||||||
|
console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도");
|
||||||
|
|
||||||
|
// 모달의 닫기 버튼을 찾아서 클릭
|
||||||
|
const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close');
|
||||||
|
if (modalCloseButton) {
|
||||||
|
(modalCloseButton as HTMLElement).click();
|
||||||
|
} else {
|
||||||
|
// ESC 키 이벤트 발생시키기
|
||||||
|
const escEvent = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27 });
|
||||||
|
document.dispatchEvent(escEvent);
|
||||||
|
}
|
||||||
|
} else if (isInPopup) {
|
||||||
|
// 팝업 창인 경우
|
||||||
|
console.log("🔄 팝업 창 닫기");
|
||||||
window.close();
|
window.close();
|
||||||
} else {
|
} else {
|
||||||
history.back();
|
// 일반 페이지인 경우 - 이전 페이지로 이동하지 않고 아무것도 하지 않음
|
||||||
|
console.log("🔄 일반 페이지에서 닫기 - 아무 동작 하지 않음");
|
||||||
|
alert("닫기 버튼이 클릭되었습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 팝업 액션
|
// 팝업 액션
|
||||||
const handlePopupAction = () => {
|
const handlePopupAction = () => {
|
||||||
if (config?.popupTitle && config?.popupContent) {
|
console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId });
|
||||||
// 커스텀 모달 대신 기본 alert 사용 (향후 모달 컴포넌트로 교체 가능)
|
|
||||||
|
if (config?.popupScreenId) {
|
||||||
|
// 화면 모달 열기
|
||||||
|
setPopupScreen({
|
||||||
|
screenId: config.popupScreenId,
|
||||||
|
title: config.popupTitle || "상세 정보",
|
||||||
|
size: "lg",
|
||||||
|
});
|
||||||
|
} else if (config?.popupTitle && config?.popupContent) {
|
||||||
|
// 텍스트 모달 표시
|
||||||
alert(`${config.popupTitle}\n\n${config.popupContent}`);
|
alert(`${config.popupTitle}\n\n${config.popupContent}`);
|
||||||
} else {
|
} else {
|
||||||
alert("팝업을 표시합니다.");
|
alert("모달을 표시합니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 네비게이션 액션
|
// 네비게이션 액션
|
||||||
const handleNavigateAction = () => {
|
const handleNavigateAction = () => {
|
||||||
if (config?.navigateUrl) {
|
const navigateType = config?.navigateType || "url";
|
||||||
|
|
||||||
|
if (navigateType === "screen" && config?.navigateScreenId) {
|
||||||
|
// 화면으로 이동
|
||||||
|
const screenPath = `/screens/${config.navigateScreenId}`;
|
||||||
|
|
||||||
|
console.log("🎯 화면으로 이동:", {
|
||||||
|
screenId: config.navigateScreenId,
|
||||||
|
target: config.navigateTarget || "_self",
|
||||||
|
path: screenPath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.navigateTarget === "_blank") {
|
||||||
|
window.open(screenPath, "_blank");
|
||||||
|
} else {
|
||||||
|
window.location.href = screenPath;
|
||||||
|
}
|
||||||
|
} else if (navigateType === "url" && config?.navigateUrl) {
|
||||||
|
// URL로 이동
|
||||||
|
console.log("🔗 URL로 이동:", {
|
||||||
|
url: config.navigateUrl,
|
||||||
|
target: config.navigateTarget || "_self"
|
||||||
|
});
|
||||||
|
|
||||||
if (config.navigateTarget === "_blank") {
|
if (config.navigateTarget === "_blank") {
|
||||||
window.open(config.navigateUrl, "_blank");
|
window.open(config.navigateUrl, "_blank");
|
||||||
} else {
|
} else {
|
||||||
window.location.href = config.navigateUrl;
|
window.location.href = config.navigateUrl;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("🔗 네비게이션 URL이 설정되지 않았습니다.");
|
console.log("🔗 네비게이션 정보가 설정되지 않았습니다:", {
|
||||||
|
navigateType,
|
||||||
|
hasUrl: !!config?.navigateUrl,
|
||||||
|
hasScreenId: !!config?.navigateScreenId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -991,7 +1290,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
size={config?.size || "sm"}
|
size="sm"
|
||||||
variant={config?.variant || "default"}
|
variant={config?.variant || "default"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
|
|
@ -1083,18 +1382,95 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
marginBottom: component.style?.labelMarginBottom || "4px",
|
marginBottom: component.style?.labelMarginBottom || "4px",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
|
||||||
{shouldShowLabel && (
|
|
||||||
<div className="block" style={labelStyle}>
|
|
||||||
{labelText}
|
|
||||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 실제 위젯 */}
|
return (
|
||||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
<>
|
||||||
</div>
|
<div className="h-full w-full">
|
||||||
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
|
{shouldShowLabel && (
|
||||||
|
<div className="block" style={labelStyle}>
|
||||||
|
{labelText}
|
||||||
|
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 실제 위젯 */}
|
||||||
|
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 화면 */}
|
||||||
|
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||||
|
setPopupScreen(null);
|
||||||
|
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto max-h-[60vh] p-2">
|
||||||
|
{popupLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-gray-500">화면을 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
) : popupLayout.length > 0 ? (
|
||||||
|
<div className="relative bg-white border rounded" style={{
|
||||||
|
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
||||||
|
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
||||||
|
minHeight: "400px",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}>
|
||||||
|
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
||||||
|
{popupLayout.map((popupComponent) => (
|
||||||
|
<div
|
||||||
|
key={popupComponent.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${popupComponent.position.x}px`,
|
||||||
|
top: `${popupComponent.position.y}px`,
|
||||||
|
width: `${popupComponent.size.width}px`,
|
||||||
|
height: `${popupComponent.size.height}px`,
|
||||||
|
zIndex: popupComponent.position.z || 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||||
|
<InteractiveScreenViewer
|
||||||
|
component={popupComponent}
|
||||||
|
allComponents={popupLayout}
|
||||||
|
hideLabel={false}
|
||||||
|
screenInfo={popupScreenInfo || undefined}
|
||||||
|
formData={popupFormData}
|
||||||
|
onFormDataChange={(newData) => {
|
||||||
|
console.log("💾 팝업 formData 업데이트:", {
|
||||||
|
newData,
|
||||||
|
newDataType: typeof newData,
|
||||||
|
newDataKeys: Object.keys(newData || {}),
|
||||||
|
prevFormData: popupFormData
|
||||||
|
});
|
||||||
|
|
||||||
|
// 잘못된 데이터 타입 체크
|
||||||
|
if (typeof newData === 'string') {
|
||||||
|
console.error("❌ 문자열이 formData로 전달됨:", newData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newData && typeof newData === 'object') {
|
||||||
|
setPopupFormData(prev => ({ ...prev, ...newData }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-gray-500">화면 데이터가 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,563 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Search, Monitor, Settings, X, Plus } from "lucide-react";
|
||||||
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import type { MenuItem } from "@/lib/api/menu";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
|
interface MenuAssignmentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
screenInfo: ScreenDefinition | null;
|
||||||
|
onAssignmentComplete?: () => void;
|
||||||
|
onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백 추가
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
screenInfo,
|
||||||
|
onAssignmentComplete,
|
||||||
|
onBackToList,
|
||||||
|
}) => {
|
||||||
|
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||||
|
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
||||||
|
const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [assigning, setAssigning] = useState(false);
|
||||||
|
const [existingScreens, setExistingScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [showReplaceDialog, setShowReplaceDialog] = useState(false);
|
||||||
|
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
|
||||||
|
const [assignmentMessage, setAssignmentMessage] = useState("");
|
||||||
|
|
||||||
|
// 메뉴 목록 로드 (관리자 메뉴만)
|
||||||
|
const loadMenus = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 화면관리는 관리자 전용 기능이므로 관리자 메뉴만 가져오기
|
||||||
|
const adminResponse = await apiClient.get("/admin/menus", { params: { menuType: "0" } });
|
||||||
|
const adminMenus = adminResponse.data?.data || [];
|
||||||
|
|
||||||
|
// 관리자 메뉴 정규화
|
||||||
|
const normalizedAdminMenus = adminMenus.map((menu: any) => ({
|
||||||
|
objid: menu.objid || menu.OBJID,
|
||||||
|
parent_obj_id: menu.parent_obj_id || menu.PARENT_OBJ_ID,
|
||||||
|
menu_name_kor: menu.menu_name_kor || menu.MENU_NAME_KOR,
|
||||||
|
menu_url: menu.menu_url || menu.MENU_URL,
|
||||||
|
menu_desc: menu.menu_desc || menu.MENU_DESC,
|
||||||
|
seq: menu.seq || menu.SEQ,
|
||||||
|
menu_type: "0", // 관리자 메뉴
|
||||||
|
status: menu.status || menu.STATUS,
|
||||||
|
lev: menu.lev || menu.LEV,
|
||||||
|
company_code: menu.company_code || menu.COMPANY_CODE,
|
||||||
|
company_name: menu.company_name || menu.COMPANY_NAME,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("로드된 관리자 메뉴 목록:", {
|
||||||
|
total: normalizedAdminMenus.length,
|
||||||
|
sample: normalizedAdminMenus.slice(0, 3),
|
||||||
|
});
|
||||||
|
setMenus(normalizedAdminMenus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("메뉴 목록 로드 실패:", error);
|
||||||
|
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달이 열릴 때 메뉴 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadMenus();
|
||||||
|
setSelectedMenuId("");
|
||||||
|
setSelectedMenu(null);
|
||||||
|
setSearchTerm("");
|
||||||
|
setAssignmentSuccess(false);
|
||||||
|
setAssignmentMessage("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 메뉴 선택 처리
|
||||||
|
const handleMenuSelect = async (menuId: string) => {
|
||||||
|
// 유효하지 않은 메뉴 ID인 경우 처리하지 않음
|
||||||
|
if (!menuId || menuId === "no-menu") {
|
||||||
|
setSelectedMenuId("");
|
||||||
|
setSelectedMenu(null);
|
||||||
|
setExistingScreens([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedMenuId(menuId);
|
||||||
|
const menu = menus.find((m) => m.objid?.toString() === menuId);
|
||||||
|
setSelectedMenu(menu || null);
|
||||||
|
|
||||||
|
// 선택된 메뉴에 할당된 화면들 확인
|
||||||
|
if (menu) {
|
||||||
|
try {
|
||||||
|
const menuObjid = parseInt(menu.objid?.toString() || "0");
|
||||||
|
if (menuObjid > 0) {
|
||||||
|
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||||
|
setExistingScreens(screens);
|
||||||
|
console.log(`메뉴 "${menu.menu_name_kor}"에 할당된 화면:`, screens);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("할당된 화면 조회 실패:", error);
|
||||||
|
setExistingScreens([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 할당 처리
|
||||||
|
const handleAssignScreen = async () => {
|
||||||
|
if (!selectedMenu || !screenInfo) {
|
||||||
|
toast.error("메뉴와 화면 정보가 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존에 할당된 화면이 있는지 확인
|
||||||
|
if (existingScreens.length > 0) {
|
||||||
|
// 이미 같은 화면이 할당되어 있는지 확인
|
||||||
|
const alreadyAssigned = existingScreens.some((screen) => screen.screenId === screenInfo.screenId);
|
||||||
|
if (alreadyAssigned) {
|
||||||
|
toast.info("이미 해당 메뉴에 할당된 화면입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 화면이 할당되어 있으면 교체 확인
|
||||||
|
setShowReplaceDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 화면이 없으면 바로 할당
|
||||||
|
await performAssignment();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 실제 할당 수행
|
||||||
|
const performAssignment = async (replaceExisting = false) => {
|
||||||
|
if (!selectedMenu || !screenInfo) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAssigning(true);
|
||||||
|
|
||||||
|
const menuObjid = parseInt(selectedMenu.objid?.toString() || "0");
|
||||||
|
if (menuObjid === 0) {
|
||||||
|
toast.error("유효하지 않은 메뉴 ID입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
|
||||||
|
if (replaceExisting && existingScreens.length > 0) {
|
||||||
|
console.log("기존 화면들 제거 중...", existingScreens);
|
||||||
|
for (const existingScreen of existingScreens) {
|
||||||
|
try {
|
||||||
|
await menuScreenApi.unassignScreenFromMenu(existingScreen.screenId, menuObjid);
|
||||||
|
console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`기존 화면 "${existingScreen.screenName}" 제거 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 화면 할당
|
||||||
|
await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid);
|
||||||
|
|
||||||
|
const successMessage = replaceExisting
|
||||||
|
? `기존 화면을 제거하고 "${screenInfo.screenName}" 화면이 "${selectedMenu.menu_name_kor}" 메뉴에 할당되었습니다.`
|
||||||
|
: `"${screenInfo.screenName}" 화면이 "${selectedMenu.menu_name_kor}" 메뉴에 성공적으로 할당되었습니다.`;
|
||||||
|
|
||||||
|
// 성공 상태 설정
|
||||||
|
setAssignmentSuccess(true);
|
||||||
|
setAssignmentMessage(successMessage);
|
||||||
|
|
||||||
|
// 할당 완료 콜백 호출
|
||||||
|
if (onAssignmentComplete) {
|
||||||
|
onAssignmentComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3초 후 자동으로 화면 목록으로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onBackToList) {
|
||||||
|
onBackToList();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("화면 할당 실패:", error);
|
||||||
|
const errorMessage = error.response?.data?.message || "화면 할당에 실패했습니다.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// "나중에 할당" 처리 - 시각적 효과 포함
|
||||||
|
const handleAssignLater = () => {
|
||||||
|
if (!screenInfo) return;
|
||||||
|
|
||||||
|
// 성공 상태 설정 (나중에 할당 메시지)
|
||||||
|
setAssignmentSuccess(true);
|
||||||
|
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
|
||||||
|
|
||||||
|
// 할당 완료 콜백 호출
|
||||||
|
if (onAssignmentComplete) {
|
||||||
|
onAssignmentComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3초 후 자동으로 화면 목록으로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onBackToList) {
|
||||||
|
onBackToList();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 메뉴 목록
|
||||||
|
const filteredMenus = menus.filter((menu) => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
menu.menu_name_kor?.toLowerCase().includes(searchLower) ||
|
||||||
|
menu.menu_url?.toLowerCase().includes(searchLower) ||
|
||||||
|
menu.menu_desc?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 옵션 생성 (계층 구조 표시)
|
||||||
|
const getMenuOptions = (): JSX.Element[] => {
|
||||||
|
if (loading) {
|
||||||
|
return [
|
||||||
|
<SelectItem key="loading" value="loading" disabled>
|
||||||
|
메뉴 로딩 중...
|
||||||
|
</SelectItem>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredMenus.length === 0) {
|
||||||
|
return [
|
||||||
|
<SelectItem key="no-menu" value="no-menu" disabled>
|
||||||
|
{searchTerm ? `"${searchTerm}"에 대한 검색 결과가 없습니다` : "메뉴가 없습니다"}
|
||||||
|
</SelectItem>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMenus
|
||||||
|
.filter((menu) => menu.objid && menu.objid.toString().trim() !== "") // objid가 유효한 메뉴만 필터링
|
||||||
|
.map((menu) => {
|
||||||
|
const indent = " ".repeat(Math.max(0, menu.lev || 0));
|
||||||
|
const menuId = menu.objid!.toString(); // 이미 필터링했으므로 non-null assertion 사용
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectItem key={menuId} value={menuId}>
|
||||||
|
{indent}
|
||||||
|
{menu.menu_name_kor}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
{assignmentSuccess ? (
|
||||||
|
// 성공 화면
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{assignmentMessage.includes("나중에")
|
||||||
|
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
|
||||||
|
: "화면이 성공적으로 메뉴에 할당되었습니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border bg-green-50 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<Monitor className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-900">{assignmentMessage}</p>
|
||||||
|
<p className="mt-1 text-xs text-green-700">3초 후 자동으로 화면 목록으로 이동합니다...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.3s]"></div>
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.15s]"></div>
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (onBackToList) {
|
||||||
|
onBackToList();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
화면 목록으로 이동
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 기본 할당 화면
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
메뉴에 화면 할당
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
{screenInfo && (
|
||||||
|
<div className="mt-2 rounded-lg border bg-blue-50 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="font-medium text-blue-900">{screenInfo.screenName}</span>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{screenInfo.screenCode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 메뉴 선택 (검색 기능 포함) */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="menu-select">할당할 메뉴 선택</Label>
|
||||||
|
<Select value={selectedMenuId} onValueChange={handleMenuSelect} disabled={loading}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loading ? "메뉴 로딩 중..." : "메뉴를 선택하세요"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
{/* 검색 입력 필드 */}
|
||||||
|
<div className="sticky top-0 z-10 border-b bg-white p-2">
|
||||||
|
<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="메뉴명, URL, 설명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation(); // 이벤트 전파 방지
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation(); // 키보드 이벤트 전파 방지
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // 클릭 이벤트 전파 방지
|
||||||
|
}}
|
||||||
|
className="h-8 pr-8 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSearchTerm("");
|
||||||
|
}}
|
||||||
|
className="absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 메뉴 옵션들 */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 메뉴 정보 */}
|
||||||
|
{selectedMenu && (
|
||||||
|
<div className="rounded-lg border bg-gray-50 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-medium">{selectedMenu.menu_name_kor}</h4>
|
||||||
|
<Badge variant="default">관리자</Badge>
|
||||||
|
<Badge variant={selectedMenu.status === "active" ? "default" : "outline"}>
|
||||||
|
{selectedMenu.status === "active" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1 text-sm text-gray-600">
|
||||||
|
{selectedMenu.menu_url && <p>URL: {selectedMenu.menu_url}</p>}
|
||||||
|
{selectedMenu.menu_desc && <p>설명: {selectedMenu.menu_desc}</p>}
|
||||||
|
{selectedMenu.company_name && <p>회사: {selectedMenu.company_name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기존 할당된 화면 정보 */}
|
||||||
|
{existingScreens.length > 0 && (
|
||||||
|
<div className="mt-3 rounded border bg-yellow-50 p-2">
|
||||||
|
<p className="text-sm font-medium text-yellow-800">
|
||||||
|
⚠️ 이미 할당된 화면 ({existingScreens.length}개)
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{existingScreens.map((screen) => (
|
||||||
|
<div key={screen.screenId} className="flex items-center gap-2 text-xs text-yellow-700">
|
||||||
|
<Monitor className="h-3 w-3" />
|
||||||
|
<span>{screen.screenName}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{screen.screenCode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-yellow-600">새 화면을 할당하면 기존 화면들이 제거됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleAssignLater} disabled={assigning}>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
나중에 할당
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAssignScreen}
|
||||||
|
disabled={!selectedMenu || assigning}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{assigning ? (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
할당 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
메뉴에 할당
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 화면 교체 확인 대화상자 */}
|
||||||
|
<Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-5 w-5 text-orange-600" />
|
||||||
|
화면 교체 확인
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기존 화면 목록 */}
|
||||||
|
<div className="rounded-lg border bg-red-50 p-3">
|
||||||
|
<p className="mb-2 text-sm font-medium text-red-800">제거될 화면 ({existingScreens.length}개):</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{existingScreens.map((screen) => (
|
||||||
|
<div key={screen.screenId} className="flex items-center gap-2 text-sm text-red-700">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
<span>{screen.screenName}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{screen.screenCode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로 할당될 화면 */}
|
||||||
|
{screenInfo && (
|
||||||
|
<div className="rounded-lg border bg-green-50 p-3">
|
||||||
|
<p className="mb-2 text-sm font-medium text-green-800">새로 할당될 화면:</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-700">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
<span>{screenInfo.screenName}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{screenInfo.screenCode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border-l-4 border-orange-400 bg-orange-50 p-3">
|
||||||
|
<p className="text-sm text-orange-800">
|
||||||
|
<strong>주의:</strong> 기존 화면들이 메뉴에서 제거되고 새 화면으로 교체됩니다. 이 작업은 되돌릴 수
|
||||||
|
없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowReplaceDialog(false)} disabled={assigning}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
setShowReplaceDialog(false);
|
||||||
|
await performAssignment(true);
|
||||||
|
}}
|
||||||
|
disabled={assigning}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
{assigning ? (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
교체 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
화면 교체
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,7 +22,8 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ButtonActionType, ButtonTypeConfig, WidgetComponent } from "@/types/screen";
|
import { ButtonActionType, ButtonTypeConfig, WidgetComponent, ScreenDefinition } from "@/types/screen";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
||||||
interface ButtonConfigPanelProps {
|
interface ButtonConfigPanelProps {
|
||||||
component: WidgetComponent;
|
component: WidgetComponent;
|
||||||
|
|
@ -31,7 +32,6 @@ interface ButtonConfigPanelProps {
|
||||||
|
|
||||||
const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.ReactNode; color: string }[] = [
|
const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.ReactNode; color: string }[] = [
|
||||||
{ value: "save", label: "저장", icon: <Save className="h-4 w-4" />, color: "#3b82f6" },
|
{ value: "save", label: "저장", icon: <Save className="h-4 w-4" />, color: "#3b82f6" },
|
||||||
{ value: "cancel", label: "취소", icon: <X className="h-4 w-4" />, color: "#6b7280" },
|
|
||||||
{ value: "delete", label: "삭제", icon: <Trash2 className="h-4 w-4" />, color: "#ef4444" },
|
{ value: "delete", label: "삭제", icon: <Trash2 className="h-4 w-4" />, color: "#ef4444" },
|
||||||
{ value: "edit", label: "수정", icon: <Edit className="h-4 w-4" />, color: "#f59e0b" },
|
{ value: "edit", label: "수정", icon: <Edit className="h-4 w-4" />, color: "#f59e0b" },
|
||||||
{ value: "add", label: "추가", icon: <Plus className="h-4 w-4" />, color: "#10b981" },
|
{ value: "add", label: "추가", icon: <Plus className="h-4 w-4" />, color: "#10b981" },
|
||||||
|
|
@ -39,7 +39,7 @@ const actionTypeOptions: { value: ButtonActionType; label: string; icon: React.R
|
||||||
{ value: "reset", label: "초기화", icon: <RotateCcw className="h-4 w-4" />, color: "#6b7280" },
|
{ value: "reset", label: "초기화", icon: <RotateCcw className="h-4 w-4" />, color: "#6b7280" },
|
||||||
{ value: "submit", label: "제출", icon: <Send className="h-4 w-4" />, color: "#059669" },
|
{ value: "submit", label: "제출", icon: <Send className="h-4 w-4" />, color: "#059669" },
|
||||||
{ value: "close", label: "닫기", icon: <X className="h-4 w-4" />, color: "#6b7280" },
|
{ value: "close", label: "닫기", icon: <X className="h-4 w-4" />, color: "#6b7280" },
|
||||||
{ value: "popup", label: "팝업 열기", icon: <ExternalLink className="h-4 w-4" />, color: "#8b5cf6" },
|
{ value: "popup", label: "모달 열기", icon: <ExternalLink className="h-4 w-4" />, color: "#8b5cf6" },
|
||||||
{ value: "navigate", label: "페이지 이동", icon: <ExternalLink className="h-4 w-4" />, color: "#0ea5e9" },
|
{ value: "navigate", label: "페이지 이동", icon: <ExternalLink className="h-4 w-4" />, color: "#0ea5e9" },
|
||||||
{ value: "custom", label: "사용자 정의", icon: <Settings className="h-4 w-4" />, color: "#64748b" },
|
{ value: "custom", label: "사용자 정의", icon: <Settings className="h-4 w-4" />, color: "#64748b" },
|
||||||
];
|
];
|
||||||
|
|
@ -52,7 +52,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
actionType: "custom" as ButtonActionType,
|
actionType: "custom" as ButtonActionType,
|
||||||
variant: "default" as ButtonVariant,
|
variant: "default" as ButtonVariant,
|
||||||
size: "sm" as ButtonSize,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -61,6 +60,30 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 화면 목록 상태
|
||||||
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [screensLoading, setScreensLoading] = useState(false);
|
||||||
|
|
||||||
|
// 화면 목록 로드 함수
|
||||||
|
const loadScreens = async () => {
|
||||||
|
try {
|
||||||
|
setScreensLoading(true);
|
||||||
|
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
||||||
|
setScreens(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setScreensLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 또는 네비게이션 액션 타입일 때 화면 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (localConfig.actionType === "popup" || localConfig.actionType === "navigate") {
|
||||||
|
loadScreens();
|
||||||
|
}
|
||||||
|
}, [localConfig.actionType]);
|
||||||
|
|
||||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {};
|
const newConfig = (component.webTypeConfig as ButtonTypeConfig) || {};
|
||||||
|
|
@ -69,7 +92,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
actionType: "custom" as ButtonActionType,
|
actionType: "custom" as ButtonActionType,
|
||||||
variant: "default" as ButtonVariant,
|
variant: "default" as ButtonVariant,
|
||||||
size: "sm" as ButtonSize,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 실제 저장된 값이 우선순위를 가지도록 설정
|
// 실제 저장된 값이 우선순위를 가지도록 설정
|
||||||
|
|
@ -121,13 +143,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
style: { ...component.style, backgroundColor: "#3b82f6", color: "#ffffff" },
|
style: { ...component.style, backgroundColor: "#3b82f6", color: "#ffffff" },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "cancel":
|
|
||||||
case "close":
|
case "close":
|
||||||
updates.variant = "outline";
|
updates.variant = "outline";
|
||||||
updates.backgroundColor = "transparent";
|
updates.backgroundColor = "transparent";
|
||||||
updates.textColor = "#6b7280";
|
updates.textColor = "#6b7280";
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
label: actionType === "cancel" ? "취소" : "닫기",
|
label: "닫기",
|
||||||
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
|
style: { ...component.style, backgroundColor: "transparent", color: "#6b7280", border: "1px solid #d1d5db" },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
@ -186,7 +207,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
updates.backgroundColor = "#8b5cf6";
|
updates.backgroundColor = "#8b5cf6";
|
||||||
updates.textColor = "#ffffff";
|
updates.textColor = "#ffffff";
|
||||||
updates.popupTitle = "상세 정보";
|
updates.popupTitle = "상세 정보";
|
||||||
updates.popupContent = "여기에 팝업 내용을 입력하세요.";
|
updates.popupContent = "여기에 모달 내용을 입력하세요.";
|
||||||
updates.popupSize = "md";
|
updates.popupSize = "md";
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
label: "상세보기",
|
label: "상세보기",
|
||||||
|
|
@ -196,6 +217,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
case "navigate":
|
case "navigate":
|
||||||
updates.backgroundColor = "#0ea5e9";
|
updates.backgroundColor = "#0ea5e9";
|
||||||
updates.textColor = "#ffffff";
|
updates.textColor = "#ffffff";
|
||||||
|
updates.navigateType = "url";
|
||||||
updates.navigateUrl = "/";
|
updates.navigateUrl = "/";
|
||||||
updates.navigateTarget = "_self";
|
updates.navigateTarget = "_self";
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
|
|
@ -313,21 +335,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">크기</Label>
|
|
||||||
<Select value={localConfig.size} onValueChange={(value) => updateConfig({ size: value as any })}>
|
|
||||||
<SelectTrigger className="h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="sm">작음</SelectItem>
|
|
||||||
<SelectItem value="default">기본</SelectItem>
|
|
||||||
<SelectItem value="lg">큼</SelectItem>
|
|
||||||
<SelectItem value="icon">아이콘</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 아이콘 설정 */}
|
{/* 아이콘 설정 */}
|
||||||
|
|
@ -367,11 +374,36 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
<ExternalLink className="h-3 w-3 text-purple-500" />
|
<ExternalLink className="h-3 w-3 text-purple-500" />
|
||||||
팝업 설정
|
모달 설정
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">팝업 제목</Label>
|
<Label className="text-xs">모달로 열 화면</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.popupScreenId?.toString() || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateConfig({
|
||||||
|
popupScreenId: value === "none" ? undefined : parseInt(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder={screensLoading ? "로딩 중..." : "화면을 선택하세요"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안함</SelectItem>
|
||||||
|
{screens.map((screen) => (
|
||||||
|
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
|
||||||
|
{screen.screenName} ({screen.screenCode})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{localConfig.popupScreenId && <p className="text-xs text-gray-500">선택된 화면이 모달로 열립니다</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">모달 제목</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localConfig.popupTitle || ""}
|
value={localConfig.popupTitle || ""}
|
||||||
onChange={(e) => updateConfig({ popupTitle: e.target.value })}
|
onChange={(e) => updateConfig({ popupTitle: e.target.value })}
|
||||||
|
|
@ -379,32 +411,18 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
{!localConfig.popupScreenId && (
|
||||||
<Label className="text-xs">팝업 크기</Label>
|
<div className="space-y-1">
|
||||||
<Select
|
<Label className="text-xs">모달 내용</Label>
|
||||||
value={localConfig.popupSize}
|
<Textarea
|
||||||
onValueChange={(value) => updateConfig({ popupSize: value as any })}
|
value={localConfig.popupContent || ""}
|
||||||
>
|
onChange={(e) => updateConfig({ popupContent: e.target.value })}
|
||||||
<SelectTrigger className="h-8">
|
placeholder="여기에 모달 내용을 입력하세요."
|
||||||
<SelectValue />
|
className="h-16 resize-none text-xs"
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
<p className="text-xs text-gray-500">화면을 선택하지 않으면 이 내용이 모달에 표시됩니다</p>
|
||||||
<SelectItem value="sm">작음</SelectItem>
|
</div>
|
||||||
<SelectItem value="md">보통</SelectItem>
|
)}
|
||||||
<SelectItem value="lg">큼</SelectItem>
|
|
||||||
<SelectItem value="xl">매우 큼</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">팝업 내용</Label>
|
|
||||||
<Textarea
|
|
||||||
value={localConfig.popupContent || ""}
|
|
||||||
onChange={(e) => updateConfig({ popupContent: e.target.value })}
|
|
||||||
placeholder="여기에 팝업 내용을 입력하세요."
|
|
||||||
className="h-16 resize-none text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -417,19 +435,67 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">이동할 URL</Label>
|
<Label className="text-xs">이동 방식</Label>
|
||||||
<Input
|
<Select
|
||||||
value={localConfig.navigateUrl || ""}
|
value={localConfig.navigateType || "url"}
|
||||||
onChange={(e) => updateConfig({ navigateUrl: e.target.value })}
|
onValueChange={(value) => updateConfig({ navigateType: value as "url" | "screen" })}
|
||||||
placeholder="/admin/users"
|
>
|
||||||
className="h-8 text-xs"
|
<SelectTrigger className="h-8">
|
||||||
/>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="url">URL 직접 입력</SelectItem>
|
||||||
|
<SelectItem value="screen">화면 선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(localConfig.navigateType || "url") === "url" ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">이동할 URL</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.navigateUrl || ""}
|
||||||
|
onChange={(e) => updateConfig({ navigateUrl: e.target.value })}
|
||||||
|
placeholder="/admin/users"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">이동할 화면</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.navigateScreenId?.toString() || ""}
|
||||||
|
onValueChange={(value) => updateConfig({ navigateScreenId: value ? parseInt(value) : undefined })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue placeholder="화면을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{screensLoading ? (
|
||||||
|
<SelectItem value="" disabled>
|
||||||
|
화면 목록 로딩중...
|
||||||
|
</SelectItem>
|
||||||
|
) : screens.length === 0 ? (
|
||||||
|
<SelectItem value="" disabled>
|
||||||
|
사용 가능한 화면이 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
screens.map((screen) => (
|
||||||
|
<SelectItem key={screen.screenId} value={screen.screenId.toString()}>
|
||||||
|
{screen.screenName} ({screen.screenCode})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">열기 방식</Label>
|
<Label className="text-xs">열기 방식</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localConfig.navigateTarget}
|
value={localConfig.navigateTarget || "_self"}
|
||||||
onValueChange={(value) => updateConfig({ navigateTarget: value as any })}
|
onValueChange={(value) => updateConfig({ navigateTarget: value as "_self" | "_blank" })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8">
|
<SelectTrigger className="h-8">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,25 @@ interface DetailSettingsPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
|
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
|
||||||
|
// 입력 가능한 웹타입들 정의
|
||||||
|
const inputableWebTypes = [
|
||||||
|
"text",
|
||||||
|
"number",
|
||||||
|
"decimal",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
"select",
|
||||||
|
"dropdown",
|
||||||
|
"textarea",
|
||||||
|
"email",
|
||||||
|
"tel",
|
||||||
|
"code",
|
||||||
|
"entity",
|
||||||
|
"file",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
];
|
||||||
|
|
||||||
// 웹타입별 상세 설정 렌더링 함수
|
// 웹타입별 상세 설정 렌더링 함수
|
||||||
const renderWebTypeConfig = React.useCallback(
|
const renderWebTypeConfig = React.useCallback(
|
||||||
(widget: WidgetComponent) => {
|
(widget: WidgetComponent) => {
|
||||||
|
|
@ -224,87 +243,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
|
||||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{widget.widgetType}</span>
|
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{widget.widgetType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-500">컬럼: {widget.columnName}</div>
|
<div className="mt-1 text-xs text-gray-500">컬럼: {widget.columnName}</div>
|
||||||
|
|
||||||
{/* 입력 타입 설정 */}
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">입력 타입</label>
|
|
||||||
<Select
|
|
||||||
value={widget.inputType || "direct"}
|
|
||||||
onValueChange={(value: "direct" | "auto") => {
|
|
||||||
onUpdateProperty(widget.id, "inputType", value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="direct">직접입력</SelectItem>
|
|
||||||
<SelectItem value="auto">자동입력</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{widget.inputType === "auto"
|
|
||||||
? "시스템에서 자동으로 값을 생성합니다 (읽기 전용)"
|
|
||||||
: "사용자가 직접 값을 입력할 수 있습니다"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 자동 값 타입 설정 (자동입력일 때만 표시) */}
|
|
||||||
{widget.inputType === "auto" && (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">자동 값 타입</label>
|
|
||||||
<Select
|
|
||||||
value={widget.autoValueType || "current_datetime"}
|
|
||||||
onValueChange={(
|
|
||||||
value:
|
|
||||||
| "current_datetime"
|
|
||||||
| "current_date"
|
|
||||||
| "current_time"
|
|
||||||
| "current_user"
|
|
||||||
| "uuid"
|
|
||||||
| "sequence"
|
|
||||||
| "user_defined",
|
|
||||||
) => {
|
|
||||||
onUpdateProperty(widget.id, "autoValueType", value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="current_datetime">현재 날짜시간</SelectItem>
|
|
||||||
<SelectItem value="current_date">현재 날짜</SelectItem>
|
|
||||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
|
||||||
<SelectItem value="current_user">현재 사용자</SelectItem>
|
|
||||||
<SelectItem value="uuid">UUID</SelectItem>
|
|
||||||
<SelectItem value="sequence">시퀀스</SelectItem>
|
|
||||||
<SelectItem value="user_defined">사용자 정의</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{(() => {
|
|
||||||
switch (widget.autoValueType || "current_datetime") {
|
|
||||||
case "current_datetime":
|
|
||||||
return "현재 날짜와 시간을 자동으로 입력합니다";
|
|
||||||
case "current_date":
|
|
||||||
return "현재 날짜를 자동으로 입력합니다";
|
|
||||||
case "current_time":
|
|
||||||
return "현재 시간을 자동으로 입력합니다";
|
|
||||||
case "current_user":
|
|
||||||
return "현재 로그인한 사용자 정보를 입력합니다";
|
|
||||||
case "uuid":
|
|
||||||
return "고유한 UUID를 생성합니다";
|
|
||||||
case "sequence":
|
|
||||||
return "순차적인 번호를 생성합니다";
|
|
||||||
case "user_defined":
|
|
||||||
return "사용자가 정의한 규칙에 따라 값을 생성합니다";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상세 설정 영역 */}
|
{/* 상세 설정 영역 */}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,25 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
|
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap, RefreshCw } from "lucide-react";
|
||||||
import { GridSettings } from "@/types/screen";
|
import { GridSettings, ScreenResolution } from "@/types/screen";
|
||||||
|
import { calculateGridInfo } from "@/lib/utils/gridUtils";
|
||||||
|
|
||||||
interface GridPanelProps {
|
interface GridPanelProps {
|
||||||
gridSettings: GridSettings;
|
gridSettings: GridSettings;
|
||||||
onGridSettingsChange: (settings: GridSettings) => void;
|
onGridSettingsChange: (settings: GridSettings) => void;
|
||||||
onResetGrid: () => void;
|
onResetGrid: () => void;
|
||||||
|
onForceGridUpdate?: () => void; // 강제 격자 재조정 추가
|
||||||
|
screenResolution?: ScreenResolution; // 해상도 정보 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettingsChange, onResetGrid }) => {
|
export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
|
gridSettings,
|
||||||
|
onGridSettingsChange,
|
||||||
|
onResetGrid,
|
||||||
|
onForceGridUpdate,
|
||||||
|
screenResolution,
|
||||||
|
}) => {
|
||||||
const updateSetting = (key: keyof GridSettings, value: any) => {
|
const updateSetting = (key: keyof GridSettings, value: any) => {
|
||||||
onGridSettingsChange({
|
onGridSettingsChange({
|
||||||
...gridSettings,
|
...gridSettings,
|
||||||
|
|
@ -24,6 +33,25 @@ export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettin
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 실제 격자 정보 계산
|
||||||
|
const actualGridInfo = screenResolution
|
||||||
|
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
|
columns: gridSettings.columns,
|
||||||
|
gap: gridSettings.gap,
|
||||||
|
padding: gridSettings.padding,
|
||||||
|
snapToGrid: gridSettings.snapToGrid || false,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 실제 표시되는 컬럼 수 계산 (항상 설정된 개수를 표시하되, 너비가 너무 작으면 경고)
|
||||||
|
const actualColumns = gridSettings.columns;
|
||||||
|
|
||||||
|
// 컬럼이 너무 작은지 확인
|
||||||
|
const isColumnsTooSmall =
|
||||||
|
screenResolution && actualGridInfo
|
||||||
|
? actualGridInfo.columnWidth < 30 // 30px 미만이면 너무 작다고 판단
|
||||||
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -34,10 +62,25 @@ export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettin
|
||||||
<h3 className="font-medium text-gray-900">격자 설정</h3>
|
<h3 className="font-medium text-gray-900">격자 설정</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="sm" variant="outline" onClick={onResetGrid} className="flex items-center space-x-1">
|
<div className="flex items-center space-x-2">
|
||||||
<RotateCcw className="h-3 w-3" />
|
{onForceGridUpdate && (
|
||||||
<span>초기화</span>
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onForceGridUpdate}
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
<span>재정렬</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" onClick={onResetGrid} className="flex items-center space-x-1">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
<span>초기화</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 주요 토글들 */}
|
{/* 주요 토글들 */}
|
||||||
|
|
@ -214,7 +257,47 @@ export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettin
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 푸터 */}
|
||||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||||
<div className="text-xs text-gray-600">💡 격자 설정은 실시간으로 캔버스에 반영됩니다</div>
|
<div className="text-xs text-gray-600">💡 격자 설정은 실시간으로 캔버스에 반영됩니다 </div>
|
||||||
|
|
||||||
|
{/* 해상도 및 격자 정보 */}
|
||||||
|
{screenResolution && actualGridInfo && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-gray-900">격자 정보</h4>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">해상도:</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{screenResolution.width} × {screenResolution.height}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">컬럼 너비:</span>
|
||||||
|
<span className={`font-mono ${isColumnsTooSmall ? "text-red-600" : "text-gray-900"}`}>
|
||||||
|
{actualGridInfo.columnWidth.toFixed(1)}px
|
||||||
|
{isColumnsTooSmall && " (너무 작음)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">사용 가능 너비:</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{(screenResolution.width - gridSettings.padding * 2).toLocaleString()}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isColumnsTooSmall && (
|
||||||
|
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-800">
|
||||||
|
💡 컬럼이 너무 작습니다. 컬럼 수를 줄이거나 간격을 줄여보세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Monitor, Tablet, Smartphone, Settings } from "lucide-react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
|
||||||
|
|
||||||
|
interface ResolutionPanelProps {
|
||||||
|
currentResolution: ScreenResolution;
|
||||||
|
onResolutionChange: (resolution: ScreenResolution) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, onResolutionChange }) => {
|
||||||
|
const [customWidth, setCustomWidth] = useState(currentResolution.width.toString());
|
||||||
|
const [customHeight, setCustomHeight] = useState(currentResolution.height.toString());
|
||||||
|
const [selectedPreset, setSelectedPreset] = useState<string>(
|
||||||
|
SCREEN_RESOLUTIONS.find((r) => r.width === currentResolution.width && r.height === currentResolution.height)
|
||||||
|
?.name || "custom",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePresetChange = (presetName: string) => {
|
||||||
|
setSelectedPreset(presetName);
|
||||||
|
|
||||||
|
if (presetName === "custom") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = SCREEN_RESOLUTIONS.find((r) => r.name === presetName);
|
||||||
|
if (preset) {
|
||||||
|
setCustomWidth(preset.width.toString());
|
||||||
|
setCustomHeight(preset.height.toString());
|
||||||
|
onResolutionChange(preset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomResolution = () => {
|
||||||
|
const width = parseInt(customWidth);
|
||||||
|
const height = parseInt(customHeight);
|
||||||
|
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
const customResolution: ScreenResolution = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
name: `사용자 정의 (${width}×${height})`,
|
||||||
|
category: "custom",
|
||||||
|
};
|
||||||
|
onResolutionChange(customResolution);
|
||||||
|
setSelectedPreset("custom");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case "desktop":
|
||||||
|
return <Monitor className="h-4 w-4" />;
|
||||||
|
case "tablet":
|
||||||
|
return <Tablet className="h-4 w-4" />;
|
||||||
|
case "mobile":
|
||||||
|
return <Smartphone className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Settings className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case "desktop":
|
||||||
|
return "text-blue-600";
|
||||||
|
case "tablet":
|
||||||
|
return "text-green-600";
|
||||||
|
case "mobile":
|
||||||
|
return "text-purple-600";
|
||||||
|
default:
|
||||||
|
return "text-gray-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 현재 해상도 표시 */}
|
||||||
|
<div className="rounded-lg border bg-gray-50 p-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{getCategoryIcon(currentResolution.category)}
|
||||||
|
<span className="text-sm font-medium">{currentResolution.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
{currentResolution.width} × {currentResolution.height} 픽셀
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프리셋 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">해상도 프리셋</Label>
|
||||||
|
<Select value={selectedPreset} onValueChange={handlePresetChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="해상도를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* Desktop */}
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-gray-500">데스크톱</div>
|
||||||
|
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
|
||||||
|
<SelectItem key={resolution.name} value={resolution.name}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Monitor className="h-4 w-4 text-blue-600" />
|
||||||
|
<span>{resolution.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tablet */}
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-gray-500">태블릿</div>
|
||||||
|
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
|
||||||
|
<SelectItem key={resolution.name} value={resolution.name}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Tablet className="h-4 w-4 text-green-600" />
|
||||||
|
<span>{resolution.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Mobile */}
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-gray-500">모바일</div>
|
||||||
|
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
|
||||||
|
<SelectItem key={resolution.name} value={resolution.name}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Smartphone className="h-4 w-4 text-purple-600" />
|
||||||
|
<span>{resolution.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Custom */}
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-gray-500">사용자 정의</div>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Settings className="h-4 w-4 text-gray-600" />
|
||||||
|
<span>사용자 정의</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 정의 해상도 */}
|
||||||
|
{selectedPreset === "custom" && (
|
||||||
|
<div className="space-y-3 rounded-lg border bg-gray-50 p-3">
|
||||||
|
<Label className="text-sm font-medium">사용자 정의 해상도</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-gray-600">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={customWidth}
|
||||||
|
onChange={(e) => setCustomWidth(e.target.value)}
|
||||||
|
placeholder="1920"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-gray-600">높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={customHeight}
|
||||||
|
onChange={(e) => setCustomHeight(e.target.value)}
|
||||||
|
placeholder="1080"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCustomResolution} size="sm" className="w-full">
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 해상도 정보 */}
|
||||||
|
<div className="space-y-2 text-xs text-gray-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>화면 비율:</span>
|
||||||
|
<span>{(currentResolution.width / currentResolution.height).toFixed(2)}:1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>총 픽셀:</span>
|
||||||
|
<span>{(currentResolution.width * currentResolution.height).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResolutionPanel;
|
||||||
|
|
@ -21,7 +21,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
pattern: "",
|
pattern: "",
|
||||||
format: "none" as const,
|
format: "none" as const,
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
|
defaultValue: "",
|
||||||
multiline: false,
|
multiline: false,
|
||||||
|
autoInput: false,
|
||||||
|
autoValueType: "current_datetime" as const,
|
||||||
|
customValue: "",
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -32,7 +36,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
pattern: safeConfig.pattern,
|
pattern: safeConfig.pattern,
|
||||||
format: safeConfig.format,
|
format: safeConfig.format,
|
||||||
placeholder: safeConfig.placeholder,
|
placeholder: safeConfig.placeholder,
|
||||||
|
defaultValue: safeConfig.defaultValue,
|
||||||
multiline: safeConfig.multiline,
|
multiline: safeConfig.multiline,
|
||||||
|
autoInput: safeConfig.autoInput,
|
||||||
|
autoValueType: safeConfig.autoValueType,
|
||||||
|
customValue: safeConfig.customValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// config가 변경될 때 로컬 상태 동기화
|
// config가 변경될 때 로컬 상태 동기화
|
||||||
|
|
@ -43,7 +51,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
pattern: safeConfig.pattern,
|
pattern: safeConfig.pattern,
|
||||||
format: safeConfig.format,
|
format: safeConfig.format,
|
||||||
placeholder: safeConfig.placeholder,
|
placeholder: safeConfig.placeholder,
|
||||||
|
defaultValue: safeConfig.defaultValue,
|
||||||
multiline: safeConfig.multiline,
|
multiline: safeConfig.multiline,
|
||||||
|
autoInput: safeConfig.autoInput,
|
||||||
|
autoValueType: safeConfig.autoValueType,
|
||||||
|
customValue: safeConfig.customValue,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
safeConfig.minLength,
|
safeConfig.minLength,
|
||||||
|
|
@ -51,7 +63,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
safeConfig.pattern,
|
safeConfig.pattern,
|
||||||
safeConfig.format,
|
safeConfig.format,
|
||||||
safeConfig.placeholder,
|
safeConfig.placeholder,
|
||||||
|
safeConfig.defaultValue,
|
||||||
safeConfig.multiline,
|
safeConfig.multiline,
|
||||||
|
safeConfig.autoInput,
|
||||||
|
safeConfig.autoValueType,
|
||||||
|
safeConfig.customValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const updateConfig = (key: keyof TextTypeConfig, value: any) => {
|
const updateConfig = (key: keyof TextTypeConfig, value: any) => {
|
||||||
|
|
@ -69,7 +85,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
pattern: key === "pattern" ? value : localValues.pattern,
|
pattern: key === "pattern" ? value : localValues.pattern,
|
||||||
format: key === "format" ? value : localValues.format,
|
format: key === "format" ? value : localValues.format,
|
||||||
placeholder: key === "placeholder" ? value : localValues.placeholder,
|
placeholder: key === "placeholder" ? value : localValues.placeholder,
|
||||||
|
defaultValue: key === "defaultValue" ? value : localValues.defaultValue,
|
||||||
multiline: key === "multiline" ? value : localValues.multiline,
|
multiline: key === "multiline" ? value : localValues.multiline,
|
||||||
|
autoInput: key === "autoInput" ? value : localValues.autoInput,
|
||||||
|
autoValueType: key === "autoValueType" ? value : localValues.autoValueType,
|
||||||
|
customValue: key === "customValue" ? value : localValues.customValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||||
|
|
@ -172,6 +192,80 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 기본값 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="defaultValue" className="text-sm font-medium">
|
||||||
|
기본값
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="defaultValue"
|
||||||
|
value={localValues.defaultValue}
|
||||||
|
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||||
|
placeholder="기본 입력값"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동입력 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="autoInput" className="text-sm font-medium">
|
||||||
|
자동입력 활성화
|
||||||
|
</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="autoInput"
|
||||||
|
checked={localValues.autoInput}
|
||||||
|
onCheckedChange={(checked) => updateConfig("autoInput", !!checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localValues.autoInput && (
|
||||||
|
<div className="space-y-3 border-l-2 border-blue-200 pl-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="autoValueType" className="text-sm font-medium">
|
||||||
|
자동값 타입
|
||||||
|
</Label>
|
||||||
|
<Select value={localValues.autoValueType} onValueChange={(value) => updateConfig("autoValueType", value)}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="자동값 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="current_datetime">현재 날짜시간</SelectItem>
|
||||||
|
<SelectItem value="current_date">현재 날짜</SelectItem>
|
||||||
|
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||||
|
<SelectItem value="current_user">현재 사용자</SelectItem>
|
||||||
|
<SelectItem value="uuid">고유 ID (UUID)</SelectItem>
|
||||||
|
<SelectItem value="sequence">순번</SelectItem>
|
||||||
|
<SelectItem value="custom">사용자 정의</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localValues.autoValueType === "custom" && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="customValue" className="text-sm font-medium">
|
||||||
|
사용자 정의 값
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="customValue"
|
||||||
|
value={localValues.customValue}
|
||||||
|
onChange={(e) => updateConfig("customValue", e.target.value)}
|
||||||
|
placeholder="사용자 정의 값을 입력하세요"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-3">
|
||||||
|
<div className="text-sm font-medium text-blue-900">자동입력 안내</div>
|
||||||
|
<div className="mt-1 text-xs text-blue-800">
|
||||||
|
자동입력이 활성화되면 해당 필드는 읽기 전용이 되며, 설정된 타입에 따라 자동으로 값이 입력됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 여러 줄 입력 */}
|
{/* 여러 줄 입력 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="multiline" className="text-sm font-medium">
|
<Label htmlFor="multiline" className="text-sm font-medium">
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function calculateGridInfo(
|
||||||
const columnWidth = (availableWidth - totalGaps) / columns;
|
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnWidth: Math.max(columnWidth, 50), // 최소 50px
|
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
||||||
totalWidth: containerWidth,
|
totalWidth: containerWidth,
|
||||||
totalHeight: containerHeight,
|
totalHeight: containerHeight,
|
||||||
};
|
};
|
||||||
|
|
@ -172,13 +172,19 @@ export function generateGridLines(
|
||||||
|
|
||||||
// 세로 격자선 (컬럼 경계)
|
// 세로 격자선 (컬럼 경계)
|
||||||
const verticalLines: number[] = [];
|
const verticalLines: number[] = [];
|
||||||
for (let i = 0; i <= columns; i++) {
|
|
||||||
const x = padding + i * (columnWidth + gap) - gap / 2;
|
// 좌측 경계선
|
||||||
if (x >= padding && x <= containerWidth - padding) {
|
verticalLines.push(padding);
|
||||||
verticalLines.push(x);
|
|
||||||
}
|
// 각 컬럼의 오른쪽 경계선들 (컬럼 사이의 격자선)
|
||||||
|
for (let i = 1; i < columns; i++) {
|
||||||
|
const x = padding + i * columnWidth + i * gap;
|
||||||
|
verticalLines.push(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 우측 경계선
|
||||||
|
verticalLines.push(containerWidth - padding);
|
||||||
|
|
||||||
// 가로 격자선 (20px 단위)
|
// 가로 격자선 (20px 단위)
|
||||||
const horizontalLines: number[] = [];
|
const horizontalLines: number[] = [];
|
||||||
for (let y = padding; y < containerHeight; y += 20) {
|
for (let y = padding; y < containerHeight; y += 20) {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export type WebType =
|
||||||
// 버튼 기능 타입 정의
|
// 버튼 기능 타입 정의
|
||||||
export type ButtonActionType =
|
export type ButtonActionType =
|
||||||
| "save" // 저장
|
| "save" // 저장
|
||||||
| "cancel" // 취소
|
|
||||||
| "delete" // 삭제
|
| "delete" // 삭제
|
||||||
| "edit" // 수정
|
| "edit" // 수정
|
||||||
| "add" // 추가
|
| "add" // 추가
|
||||||
|
|
@ -35,7 +34,7 @@ export type ButtonActionType =
|
||||||
| "reset" // 초기화
|
| "reset" // 초기화
|
||||||
| "submit" // 제출
|
| "submit" // 제출
|
||||||
| "close" // 닫기
|
| "close" // 닫기
|
||||||
| "popup" // 팝업 열기
|
| "popup" // 모달 열기
|
||||||
| "navigate" // 페이지 이동
|
| "navigate" // 페이지 이동
|
||||||
| "custom"; // 사용자 정의
|
| "custom"; // 사용자 정의
|
||||||
|
|
||||||
|
|
@ -343,6 +342,7 @@ export type ComponentData =
|
||||||
export interface LayoutData {
|
export interface LayoutData {
|
||||||
components: ComponentData[];
|
components: ComponentData[];
|
||||||
gridSettings?: GridSettings;
|
gridSettings?: GridSettings;
|
||||||
|
screenResolution?: ScreenResolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 설정
|
// 그리드 설정
|
||||||
|
|
@ -569,10 +569,23 @@ export interface TextTypeConfig {
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
pattern?: string; // 정규식 패턴
|
pattern?: string; // 정규식 패턴
|
||||||
format?: "none" | "email" | "phone" | "url" | "korean" | "english";
|
format?: "none" | "email" | "phone" | "url" | "korean" | "english" | "alphanumeric" | "numeric";
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
defaultValue?: string; // 기본값
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
spellcheck?: boolean;
|
spellcheck?: boolean;
|
||||||
|
multiline?: boolean; // 여러 줄 입력 여부
|
||||||
|
// 자동입력 관련 설정
|
||||||
|
autoInput?: boolean; // 자동입력 활성화
|
||||||
|
autoValueType?:
|
||||||
|
| "current_datetime"
|
||||||
|
| "current_date"
|
||||||
|
| "current_time"
|
||||||
|
| "current_user"
|
||||||
|
| "uuid"
|
||||||
|
| "sequence"
|
||||||
|
| "custom"; // 자동값 타입
|
||||||
|
customValue?: string; // 사용자 정의 값
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 타입 설정
|
// 파일 타입 설정
|
||||||
|
|
@ -632,17 +645,18 @@ export interface EntityTypeConfig {
|
||||||
export interface ButtonTypeConfig {
|
export interface ButtonTypeConfig {
|
||||||
actionType: ButtonActionType;
|
actionType: ButtonActionType;
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||||
size?: "default" | "sm" | "lg" | "icon";
|
|
||||||
icon?: string; // Lucide 아이콘 이름
|
icon?: string; // Lucide 아이콘 이름
|
||||||
confirmMessage?: string; // 확인 메시지 (delete, submit 등에서 사용)
|
confirmMessage?: string; // 확인 메시지 (delete, submit 등에서 사용)
|
||||||
|
|
||||||
// 팝업 관련 설정
|
// 모달 관련 설정
|
||||||
popupTitle?: string;
|
popupTitle?: string;
|
||||||
popupContent?: string;
|
popupContent?: string;
|
||||||
popupSize?: "sm" | "md" | "lg" | "xl";
|
popupScreenId?: number; // 모달로 열 화면 ID
|
||||||
|
|
||||||
// 네비게이션 관련 설정
|
// 네비게이션 관련 설정
|
||||||
|
navigateType?: "url" | "screen"; // 네비게이션 방식: URL 직접 입력 또는 화면 선택
|
||||||
navigateUrl?: string;
|
navigateUrl?: string;
|
||||||
|
navigateScreenId?: number; // 이동할 화면 ID
|
||||||
navigateTarget?: "_self" | "_blank";
|
navigateTarget?: "_self" | "_blank";
|
||||||
|
|
||||||
// 커스텀 액션 설정
|
// 커스텀 액션 설정
|
||||||
|
|
@ -654,6 +668,33 @@ export interface ButtonTypeConfig {
|
||||||
borderColor?: string;
|
borderColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 화면 해상도 설정
|
||||||
|
export interface ScreenResolution {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
name: string;
|
||||||
|
category: "desktop" | "tablet" | "mobile" | "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리 정의된 해상도 프리셋
|
||||||
|
export const SCREEN_RESOLUTIONS: ScreenResolution[] = [
|
||||||
|
// Desktop
|
||||||
|
{ width: 1920, height: 1080, name: "Full HD (1920×1080)", category: "desktop" },
|
||||||
|
{ width: 1366, height: 768, name: "HD (1366×768)", category: "desktop" },
|
||||||
|
{ width: 1440, height: 900, name: "WXGA+ (1440×900)", category: "desktop" },
|
||||||
|
{ width: 1280, height: 1024, name: "SXGA (1280×1024)", category: "desktop" },
|
||||||
|
|
||||||
|
// Tablet
|
||||||
|
{ width: 1024, height: 768, name: "iPad (1024×768)", category: "tablet" },
|
||||||
|
{ width: 768, height: 1024, name: "iPad Portrait (768×1024)", category: "tablet" },
|
||||||
|
{ width: 1112, height: 834, name: "iPad Pro 10.5 (1112×834)", category: "tablet" },
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
{ width: 375, height: 667, name: "iPhone SE (375×667)", category: "mobile" },
|
||||||
|
{ width: 414, height: 896, name: "iPhone 11 (414×896)", category: "mobile" },
|
||||||
|
{ width: 360, height: 640, name: "Android (360×640)", category: "mobile" },
|
||||||
|
];
|
||||||
|
|
||||||
// 웹타입별 설정 유니온 타입
|
// 웹타입별 설정 유니온 타입
|
||||||
export type WebTypeConfig =
|
export type WebTypeConfig =
|
||||||
| DateTypeConfig
|
| DateTypeConfig
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue