lhj #124

Merged
hjlee merged 4 commits from lhj into main 2025-10-21 17:35:22 +09:00
8 changed files with 929 additions and 240 deletions

View File

@ -34,16 +34,35 @@ export class RiskAlertCacheService {
*/
public startAutoRefresh(): void {
console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)');
console.log(' - 기상특보: 즉시 호출');
console.log(' - 교통사고/도로공사: 10분 후 첫 호출');
// 즉시 첫 갱신
this.refreshCache();
// 기상특보만 즉시 호출 (ITS API는 10분 후부터)
this.refreshWeatherOnly();
// 10분마다 갱신 (600,000ms)
// 10분마다 전체 갱신 (600,000ms)
this.updateInterval = setInterval(() => {
this.refreshCache();
}, 10 * 60 * 1000);
}
/**
* ( )
*/
private async refreshWeatherOnly(): Promise<void> {
try {
console.log('🌤️ 기상특보만 즉시 갱신 중...');
const weatherAlerts = await this.riskAlertService.getWeatherAlerts();
this.cachedAlerts = weatherAlerts;
this.lastUpdated = new Date();
console.log(`✅ 기상특보 갱신 완료! (${weatherAlerts.length}건)`);
} catch (error: any) {
console.error('❌ 기상특보 갱신 실패:', error.message);
}
}
/**
*
*/

View File

@ -33,11 +33,11 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [newAlertIds, setNewAlertIds] = useState<Set<string>>(new Set());
// 데이터 로드 (백엔드 통합 호출)
// 데이터 로드 (백엔드 캐시 조회)
const loadData = async () => {
setIsRefreshing(true);
try {
// 백엔드 API 호출 (교통사고, 기상특보, 도로공사 통합)
// 백엔드 API 호출 (캐시된 데이터)
const response = await apiClient.get<{
success: boolean;
data: Alert[];
@ -79,6 +79,48 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
}
};
// 강제 새로고침 (실시간 API 호출)
const forceRefresh = async () => {
setIsRefreshing(true);
try {
// 강제 갱신 API 호출 (실시간 데이터)
const response = await apiClient.post<{
success: boolean;
data: Alert[];
count: number;
message?: string;
}>("/risk-alerts/refresh", {});
if (response.data.success && response.data.data) {
const newData = response.data.data;
// 새로운 알림 감지
const oldIds = new Set(alerts.map(a => a.id));
const newIds = new Set<string>();
newData.forEach(alert => {
if (!oldIds.has(alert.id)) {
newIds.add(alert.id);
}
});
setAlerts(newData);
setNewAlertIds(newIds);
setLastUpdated(new Date());
// 3초 후 새 알림 애니메이션 제거
if (newIds.size > 0) {
setTimeout(() => setNewAlertIds(new Set()), 3000);
}
} else {
console.error("❌ 리스크 알림 강제 갱신 실패");
}
} catch (error: any) {
console.error("❌ 리스크 알림 강제 갱신 오류:", error.message);
} finally {
setIsRefreshing(false);
}
};
useEffect(() => {
loadData();
// 1분마다 자동 새로고침 (60000ms)
@ -156,7 +198,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing}>
<Button variant="ghost" size="sm" onClick={forceRefresh} disabled={isRefreshing} title="실시간 데이터 갱신">
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
</div>

View File

@ -420,37 +420,39 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
timestamp: new Date().toISOString(),
});
const targetComponent = layout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
// 🔥 함수형 업데이트로 변경하여 최신 layout 사용
setLayout((prevLayout) => {
const targetComponent = prevLayout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
const positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) {
const oldPosition = targetComponent.position;
let newPosition = { ...oldPosition };
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
const positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) {
const oldPosition = targetComponent.position;
let newPosition = { ...oldPosition };
if (path === "position.x") {
newPosition.x = value;
positionDelta.x = value - oldPosition.x;
} else if (path === "position.y") {
newPosition.y = value;
positionDelta.y = value - oldPosition.y;
} else if (path === "position") {
newPosition = value;
positionDelta.x = value.x - oldPosition.x;
positionDelta.y = value.y - oldPosition.y;
if (path === "position.x") {
newPosition.x = value;
positionDelta.x = value - oldPosition.x;
} else if (path === "position.y") {
newPosition.y = value;
positionDelta.y = value - oldPosition.y;
} else if (path === "position") {
newPosition = value;
positionDelta.x = value.x - oldPosition.x;
positionDelta.y = value.y - oldPosition.y;
}
console.log("📐 레이아웃 이동 감지:", {
layoutId: componentId,
oldPosition,
newPosition,
positionDelta,
});
}
console.log("📐 레이아웃 이동 감지:", {
layoutId: componentId,
oldPosition,
newPosition,
positionDelta,
});
}
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
const pathParts = path.split(".");
const updatedComponents = prevLayout.components.map((comp) => {
if (comp.id !== componentId) {
// 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동
if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) {
@ -480,22 +482,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 중첩 경로를 고려한 안전한 복사
const newComp = { ...comp };
console.log("🔍 업데이트 전 상태:", {
path,
value,
"기존 componentConfig": newComp.componentConfig,
"기존 action": (newComp as any).componentConfig?.action,
});
// 경로를 따라 내려가면서 각 레벨을 새 객체로 복사
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
const key = pathParts[i];
console.log(`🔍 경로 탐색 [${i}]: key="${key}", current[key]=`, current[key]);
// 다음 레벨이 없거나 객체가 아니면 새 객체 생성
if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) {
console.log(`🆕 새 객체 생성: ${key}`);
current[key] = {};
} else {
// 기존 객체를 복사하여 불변성 유지
console.log(`📋 기존 객체 복사: ${key}`, { ...current[key] });
current[key] = { ...current[key] };
}
current = current[key];
}
// 최종 값 설정
current[pathParts[pathParts.length - 1]] = value;
const finalKey = pathParts[pathParts.length - 1];
console.log(`✍️ 최종 값 설정: ${finalKey} = ${value}`);
current[finalKey] = value;
console.log("✅ 컴포넌트 업데이트 완료:", {
componentId,
@ -551,25 +566,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
if (
(path === "size.width" || path === "size.height") &&
layout.gridSettings?.snapToGrid &&
prevLayout.gridSettings?.snapToGrid &&
gridInfo &&
newComp.type !== "group"
) {
// 현재 해상도에 맞는 격자 정보로 스냅 적용
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, layout.gridSettings as GridUtilSettings);
const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, prevLayout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(
newComp,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
prevLayout.gridSettings as GridUtilSettings,
);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
@ -582,19 +597,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
if (path === "gridColumns" && layout.gridSettings?.snapToGrid && newComp.type !== "group") {
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
// gridColumns에 맞는 정확한 너비 계산
const newWidth = calculateWidthFromColumns(
newComp.gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = {
...newComp.size,
@ -699,52 +714,71 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}
return newComp;
});
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
// 🔥 새로운 layout 생성
const newLayout = { ...prevLayout, components: updatedComponents };
console.log("🔄 setLayout 실행:", {
componentId,
path,
value,
업데이트된컴포넌트: updatedComponents.find((c) => c.id === componentId),
});
saveToHistory(newLayout);
// selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트
setSelectedComponent((prevSelected) => {
if (prevSelected && prevSelected.id === componentId) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedSelectedComponent) {
// 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함
const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent));
console.log("🔄 selectedComponent 동기화:", {
componentId,
path,
oldAction: (prevSelected as any).componentConfig?.action,
newAction: (newSelectedComponent as any).componentConfig?.action,
oldColumnsCount:
prevSelected.type === "datatable" ? (prevSelected as any).columns?.length : "N/A",
newColumnsCount:
newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).columns?.length : "N/A",
oldFiltersCount:
prevSelected.type === "datatable" ? (prevSelected as any).filters?.length : "N/A",
newFiltersCount:
newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).filters?.length : "N/A",
timestamp: new Date().toISOString(),
});
return newSelectedComponent;
}
}
return prevSelected;
});
// selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트
if (selectedComponent && selectedComponent.id === componentId) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedSelectedComponent) {
console.log("🔄 selectedComponent 동기화:", {
// webTypeConfig 업데이트 후 레이아웃 상태 확인
if (path === "webTypeConfig") {
const updatedComponent = newLayout.components.find((c) => c.id === componentId);
console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", {
componentId,
path,
oldColumnsCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A",
newColumnsCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A",
oldFiltersCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A",
newFiltersCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A",
updatedComponent: updatedComponent
? {
id: updatedComponent.id,
type: updatedComponent.type,
webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null,
}
: null,
layoutComponentsCount: newLayout.components.length,
timestamp: new Date().toISOString(),
});
setSelectedComponent(updatedSelectedComponent);
}
}
// webTypeConfig 업데이트 후 레이아웃 상태 확인
if (path === "webTypeConfig") {
const updatedComponent = newLayout.components.find((c) => c.id === componentId);
console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", {
componentId,
updatedComponent: updatedComponent
? {
id: updatedComponent.id,
type: updatedComponent.type,
webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null,
}
: null,
layoutComponentsCount: newLayout.components.length,
timestamp: new Date().toISOString(),
});
}
return newLayout;
});
},
[layout, gridInfo, saveToHistory],
[gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거!
);
// 컴포넌트 시스템 초기화
@ -1294,11 +1328,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
components: updatedComponents,
screenResolution: screenResolution,
};
// 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary"
);
console.log("💾 저장 시작:", {
screenId: selectedScreen.screenId,
componentsCount: layoutWithResolution.components.length,
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
buttonComponents: buttonComponents.map((c: any) => ({
id: c.id,
type: c.type,
text: c.componentConfig?.text,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,
})),
});
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
@ -2127,7 +2172,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 새 컴포넌트 선택
setSelectedComponent(newComponent);
openPanel("properties");
// 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화
// openPanel("properties");
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
@ -2610,8 +2656,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
// 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화
// openPanel("properties");
} catch (error) {
// console.error("드롭 처리 실패:", error);
}
@ -2674,47 +2720,66 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return;
}
// 🔧 layout.components에서 최신 버전의 컴포넌트 찾기
const latestComponent = layout.components.find((c) => c.id === component.id);
if (!latestComponent) {
console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id);
return;
}
console.log("🔍 컴포넌트 클릭 시 최신 버전 확인:", {
componentId: component.id,
: {
actionType: (component as any).componentConfig?.action?.type,
fullAction: (component as any).componentConfig?.action,
},
layout에서찾은최신버전: {
actionType: (latestComponent as any).componentConfig?.action?.type,
fullAction: (latestComponent as any).componentConfig?.action,
},
});
const isShiftPressed = event?.shiftKey || false;
const isCtrlPressed = event?.ctrlKey || event?.metaKey || false;
const isGroupContainer = component.type === "group";
const isGroupContainer = latestComponent.type === "group";
if (isShiftPressed || isCtrlPressed || groupState.isGrouping) {
// 다중 선택 모드
if (isGroupContainer) {
// 그룹 컨테이너는 단일 선택으로 처리
handleComponentSelect(component);
handleComponentSelect(latestComponent); // 🔧 최신 버전 사용
setGroupState((prev) => ({
...prev,
selectedComponents: [component.id],
selectedComponents: [latestComponent.id],
isGrouping: false,
}));
return;
}
const isSelected = groupState.selectedComponents.includes(component.id);
const isSelected = groupState.selectedComponents.includes(latestComponent.id);
setGroupState((prev) => ({
...prev,
selectedComponents: isSelected
? prev.selectedComponents.filter((id) => id !== component.id)
: [...prev.selectedComponents, component.id],
? prev.selectedComponents.filter((id) => id !== latestComponent.id)
: [...prev.selectedComponents, latestComponent.id],
}));
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
if (!isSelected) {
// console.log("🎯 컴포넌트 선택 (다중 모드):", component.id);
handleComponentSelect(component);
// console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id);
handleComponentSelect(latestComponent); // 🔧 최신 버전 사용
}
} else {
// 단일 선택 모드
// console.log("🎯 컴포넌트 선택 (단일 모드):", component.id);
handleComponentSelect(component);
// console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id);
handleComponentSelect(latestComponent); // 🔧 최신 버전 사용
setGroupState((prev) => ({
...prev,
selectedComponents: [component.id],
selectedComponents: [latestComponent.id],
}));
}
},
[handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag],
[handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag, layout.components],
);
// 컴포넌트 드래그 시작

View File

@ -0,0 +1,625 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
interface ScreenOption {
id: number;
name: string;
description?: string;
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
// 🔧 항상 최신 component에서 직접 참조
const config = component.componentConfig || {};
const currentAction = component.componentConfig?.action || {}; // 🔧 최신 action 참조
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
});
const [localSelects, setLocalSelects] = useState({
variant: config.variant || "default",
size: config.size || "md", // 🔧 기본값을 "md"로 변경
actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined)
modalSize: config.action?.modalSize || "md",
editMode: config.action?.editMode || "modal",
});
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
const [navScreenOpen, setNavScreenOpen] = useState(false);
const [modalSearchTerm, setModalSearchTerm] = useState("");
const [navSearchTerm, setNavSearchTerm] = useState("");
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
console.log("🔄 ButtonConfigPanel useEffect 실행:", {
componentId: component.id,
"config.action?.type": config.action?.type,
"localSelects.actionType (before)": localSelects.actionType,
fullAction: config.action,
"component.componentConfig.action": component.componentConfig?.action,
});
setLocalInputs({
text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
});
setLocalSelects((prev) => {
const newSelects = {
variant: config.variant || "default",
size: config.size || "md", // 🔧 기본값을 "md"로 변경
actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined)
modalSize: config.action?.modalSize || "md",
editMode: config.action?.editMode || "modal",
};
console.log("📝 setLocalSelects 호출:", {
"prev.actionType": prev.actionType,
"new.actionType": newSelects.actionType,
"config.action?.type": config.action?.type,
});
return newSelects;
});
}, [
component.id, // 🔧 컴포넌트 ID (다른 컴포넌트로 전환 시)
component.componentConfig?.action?.type, // 🔧 액션 타입 (액션 변경 시 즉시 반영)
component.componentConfig?.text, // 🔧 버튼 텍스트
component.componentConfig?.variant, // 🔧 버튼 스타일
component.componentConfig?.size, // 🔧 버튼 크기
]);
// 화면 목록 가져오기
useEffect(() => {
const fetchScreens = async () => {
try {
setScreensLoading(true);
const response = await apiClient.get("/screen-management/screens");
if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({
id: screen.screenId,
name: screen.screenName,
description: screen.description,
}));
setScreens(screenList);
}
} catch (error) {
// console.error("❌ 화면 목록 로딩 실패:", error);
} finally {
setScreensLoading(false);
}
};
fetchScreens();
}, []);
// 검색 필터링 함수
const filterScreens = (searchTerm: string) => {
if (!searchTerm.trim()) return screens;
return screens.filter(
(screen) =>
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
);
};
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
component,
config,
action: config.action,
actionType: config.action?.type,
screensCount: screens.length,
});
return (
<div className="space-y-4">
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
<div>
<Label htmlFor="button-variant"> </Label>
<Select
value={localSelects.variant}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, variant: value }));
onUpdateProperty("componentConfig.variant", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="primary"> (Primary)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="danger"> (Danger)</SelectItem>
<SelectItem value="success"> (Success)</SelectItem>
<SelectItem value="outline"> (Outline)</SelectItem>
<SelectItem value="ghost"> (Ghost)</SelectItem>
<SelectItem value="link"> (Link)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="button-size"> </Label>
<Select
value={localSelects.size}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, size: value }));
onUpdateProperty("componentConfig.size", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 글씨 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Default)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="button-action"> </Label>
<Select
value={localSelects.actionType || undefined}
onValueChange={(value) => {
console.log("🔵 버튼 액션 변경 시작:", {
oldValue: localSelects.actionType,
newValue: value,
componentId: component.id,
"현재 component.componentConfig.action": component.componentConfig?.action,
});
// 로컬 상태 업데이트
setLocalSelects((prev) => {
console.log("📝 setLocalSelects (액션 변경):", {
"prev.actionType": prev.actionType,
"new.actionType": value,
});
return { ...prev, actionType: value };
});
// 🔧 개별 속성만 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 액션에 따른 라벨 색상 자동 설정 (별도 호출)
if (value === "delete") {
onUpdateProperty("style.labelColor", "#ef4444");
} else {
onUpdateProperty("style.labelColor", "#212121");
}
console.log("✅ 버튼 액션 변경 완료");
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="save"></SelectItem>
<SelectItem value="cancel"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="add"></SelectItem>
<SelectItem value="search"></SelectItem>
<SelectItem value="reset"></SelectItem>
<SelectItem value="submit"></SelectItem>
<SelectItem value="close"></SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="control"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 열기 액션 설정 */}
{localSelects.actionType === "modal" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="modal-title"> </Label>
<Input
id="modal-title"
placeholder="모달 제목을 입력하세요"
value={localInputs.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action.modalTitle", newValue);
}}
/>
</div>
<div>
<Label htmlFor="modal-size"> </Label>
<Select
value={localSelects.modalSize}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-modal"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 수정 액션 설정 */}
{localSelects.actionType === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="edit-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"수정 폼 화면을 선택하세요..."
: "수정 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`edit-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
<div>
<Label htmlFor="edit-mode"> </Label>
<Select
value={localSelects.editMode}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, editMode: value }));
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="수정 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
{localSelects.editMode === "modal" && (
<>
<div>
<Label htmlFor="edit-modal-title"> </Label>
<Input
id="edit-modal-title"
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
value={localInputs.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label htmlFor="edit-modal-description"> </Label>
<Input
id="edit-modal-description"
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
value={localInputs.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label htmlFor="edit-modal-size"> </Label>
<Select
value={localSelects.modalSize}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 페이지 이동 액션 설정 */}
{localSelects.actionType === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="target-screen-nav"> </Label>
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={navScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={navSearchTerm}
onChange={(e) => setNavSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(navSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`navigate-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setNavScreenOpen(false);
setNavSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
/screens/{"{"}ID{"}"}
</p>
</div>
<div>
<Label htmlFor="target-url"> URL ()</Label>
<Input
id="target-url"
placeholder="예: /admin/users 또는 https://example.com"
value={localInputs.targetUrl}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
onUpdateProperty("componentConfig.action.targetUrl", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500">URL을 </p>
</div>
</div>
)}
{/* 🔥 NEW: 제어관리 기능 섹션 */}
<div className="mt-8 border-t border-gray-200 pt-6">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900">🔧 </h3>
<p className="text-muted-foreground mt-1 text-sm"> </p>
</div>
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
</div>
);
};

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -26,25 +26,24 @@ interface ScreenOption {
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
console.log("🎨 ButtonConfigPanel 렌더링:", {
componentId: component.id,
"component.componentConfig?.action?.type": component.componentConfig?.action?.type,
});
// 🔧 component에서 직접 읽기 (useMemo 제거)
const config = component.componentConfig || {};
const currentAction = component.componentConfig?.action || {};
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text || "버튼",
text: config.text !== undefined ? config.text : "버튼",
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
});
const [localSelects, setLocalSelects] = useState({
variant: config.variant || "default",
size: config.size || "default",
actionType: config.action?.type || "save",
modalSize: config.action?.modalSize || "md",
editMode: config.action?.editMode || "modal",
});
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
@ -52,44 +51,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
const [modalSearchTerm, setModalSearchTerm] = useState("");
const [navSearchTerm, setNavSearchTerm] = useState("");
// 컴포넌트 변경 시 로컬 상태 동기화
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
useEffect(() => {
setLocalInputs({
text: config.text || "버튼",
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
});
const latestConfig = component.componentConfig || {};
const latestAction = latestConfig.action || {};
setLocalSelects({
variant: config.variant || "default",
size: config.size || "default",
actionType: config.action?.type || "save",
modalSize: config.action?.modalSize || "md",
editMode: config.action?.editMode || "modal",
setLocalInputs({
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
modalTitle: latestAction.modalTitle || "",
editModalTitle: latestAction.editModalTitle || "",
editModalDescription: latestAction.editModalDescription || "",
targetUrl: latestAction.targetUrl || "",
});
}, [
config.text,
config.variant,
config.size,
config.action?.type,
config.action?.modalTitle,
config.action?.modalSize,
config.action?.editMode,
config.action?.editModalTitle,
config.action?.editModalDescription,
config.action?.targetUrl,
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
// 화면 목록 가져오기
useEffect(() => {
const fetchScreens = async () => {
try {
setScreensLoading(true);
// console.log("🔍 화면 목록 API 호출 시작");
const response = await apiClient.get("/screen-management/screens");
// console.log("✅ 화면 목록 API 응답:", response.data);
if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({
@ -98,7 +80,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
description: screen.description,
}));
setScreens(screenList);
// console.log("✅ 화면 목록 설정 완료:", screenList.length, "개");
}
} catch (error) {
// console.error("❌ 화면 목록 로딩 실패:", error);
@ -120,13 +101,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
);
};
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
component,
config,
action: config.action,
actionType: config.action?.type,
screensCount: screens.length,
});
// console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
// component,
// config,
// action: config.action,
// actionType: config.action?.type,
// screensCount: screens.length,
// });
return (
<div className="space-y-4">
@ -147,9 +128,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<div>
<Label htmlFor="button-variant"> </Label>
<Select
value={localSelects.variant}
value={component.componentConfig?.variant || "default"}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, variant: value }));
onUpdateProperty("componentConfig.variant", value);
}}
>
@ -169,21 +149,20 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</div>
<div>
<Label htmlFor="button-size"> </Label>
<Label htmlFor="button-size"> </Label>
<Select
value={localSelects.size}
value={component.componentConfig?.size || "md"}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, size: value }));
onUpdateProperty("componentConfig.size", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 크기 선택" />
<SelectValue placeholder="버튼 글씨 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"> (Small)</SelectItem>
<SelectItem value="default"> (Default)</SelectItem>
<SelectItem value="large"> (Large)</SelectItem>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Default)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
@ -191,28 +170,23 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<div>
<Label htmlFor="button-action"> </Label>
<Select
value={localSelects.actionType}
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
value={component.componentConfig?.action?.type || "save"}
onValueChange={(value) => {
console.log("🔵 버튼 액션 변경:", {
oldValue: localSelects.actionType,
newValue: value,
componentId: component.id,
});
// 로컬 상태 업데이트
setLocalSelects((prev) => ({ ...prev, actionType: value }));
// 액션 타입 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 액션에 따른 라벨 색상 자동 설정 (별도 호출)
if (value === "delete") {
// 삭제 액션일 때 빨간색으로 설정
onUpdateProperty("style.labelColor", "#ef4444");
} else {
// 다른 액션일 때 기본색으로 리셋
onUpdateProperty("style.labelColor", "#212121");
}
console.log("🎯 버튼 액션 드롭다운 변경:", {
oldValue: component.componentConfig?.action?.type,
newValue: value,
});
// 🔥 action.type 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
setTimeout(() => {
const newColor = value === "delete" ? "#ef4444" : "#212121";
console.log("🎨 라벨 색상 업데이트:", { value, newColor });
onUpdateProperty("style.labelColor", newColor);
}, 100); // 0 → 100ms로 증가
}}
>
<SelectTrigger>
@ -236,7 +210,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</div>
{/* 모달 열기 액션 설정 */}
{localSelects.actionType === "modal" && (
{(component.componentConfig?.action?.type || "save") === "modal" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
@ -249,10 +223,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action", {
...config.action,
modalTitle: newValue,
});
onUpdateProperty("componentConfig.action.modalTitle", newValue);
}}
/>
</div>
@ -260,13 +231,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<div>
<Label htmlFor="modal-size"> </Label>
<Select
value={localSelects.modalSize}
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
onUpdateProperty("componentConfig.action", {
...config.action,
modalSize: value,
});
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
@ -301,7 +268,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
{/* 검색 입력 */}
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
@ -311,7 +277,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
{/* 검색 결과 */}
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
@ -326,10 +291,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
key={`modal-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action", {
...config.action,
targetScreenId: screen.id,
});
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
@ -356,7 +318,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
)}
{/* 수정 액션 설정 */}
{localSelects.actionType === "edit" && (
{(component.componentConfig?.action?.type || "save") === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
@ -380,7 +342,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
{/* 검색 입력 */}
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
@ -390,7 +351,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
{/* 검색 결과 */}
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
@ -405,10 +365,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
key={`edit-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action", {
...config.action,
targetScreenId: screen.id,
});
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
@ -438,13 +395,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<div>
<Label htmlFor="edit-mode"> </Label>
<Select
value={localSelects.editMode}
value={component.componentConfig?.action?.editMode || "modal"}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, editMode: value }));
onUpdateProperty("componentConfig.action", {
...config.action,
editMode: value,
});
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
@ -458,7 +411,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</Select>
</div>
{localSelects.editMode === "modal" && (
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
<>
<div>
<Label htmlFor="edit-modal-title"> </Label>
@ -469,11 +422,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action", {
...config.action,
editModalTitle: newValue,
});
// webTypeConfig에도 저장
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
@ -489,11 +438,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action", {
...config.action,
editModalDescription: newValue,
});
// webTypeConfig에도 저장
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
@ -503,13 +448,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<div>
<Label htmlFor="edit-modal-size"> </Label>
<Select
value={localSelects.modalSize}
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
onUpdateProperty("componentConfig.action", {
...config.action,
modalSize: value,
});
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
@ -530,7 +471,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
)}
{/* 페이지 이동 액션 설정 */}
{localSelects.actionType === "navigate" && (
{(component.componentConfig?.action?.type || "save") === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
@ -554,7 +495,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
{/* 검색 입력 */}
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
@ -564,7 +504,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
{/* 검색 결과 */}
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(navSearchTerm);
@ -579,10 +518,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
key={`navigate-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action", {
...config.action,
targetScreenId: screen.id,
});
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setNavScreenOpen(false);
setNavSearchTerm("");
}}
@ -618,10 +554,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
onUpdateProperty("componentConfig.action", {
...config.action,
targetUrl: newValue,
});
onUpdateProperty("componentConfig.action.targetUrl", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500">URL을 </p>
@ -641,3 +574,4 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</div>
);
};

View File

@ -822,7 +822,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
case "button":
case "button-primary":
case "button-secondary":
return <NewButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
// 🔧 component.id만 key로 사용 (unmount 방지)
return <NewButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "card":
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

View File

@ -123,7 +123,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
case "button":
case "button-primary":
case "button-secondary":
return <ButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
// 🔧 component.id만 key로 사용 (unmount 방지)
return <ButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "card":
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

View File

@ -491,7 +491,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
color: componentConfig.disabled ? "#9ca3af" : "white",
fontSize: "0.875rem",
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
fontWeight: "600",
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
outline: "none",
@ -499,10 +500,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 1rem",
// 🔧 크기에 따른 패딩 조정
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
margin: "0",
lineHeight: "1.25",
minHeight: "2.25rem",
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
@ -511,7 +512,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{processedConfig.text || component.label || "버튼"}
{/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */}
{processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
</button>
</div>