Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-11-28 18:38:18 +09:00
97 changed files with 10731 additions and 3480 deletions

View File

@@ -6,7 +6,6 @@ import {
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
@@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react";
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useAuth } from "@/hooks/useAuth";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
interface CreateScreenModalProps {
open: boolean;
@@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
const [tableSearchTerm, setTableSearchTerm] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
// 외부 DB 연결 관련 상태
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
const [externalConnections, setExternalConnections] = useState<any[]>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
// REST API 연결 관련 상태
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
const [restApiEndpoint, setRestApiEndpoint] = useState("");
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
// 화면 코드 자동 생성
const generateCode = async () => {
try {
@@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
loadConnections();
}, [open]);
// REST API 연결 목록 로드
useEffect(() => {
if (!open) return;
const loadRestApiConnections = async () => {
try {
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
setRestApiConnections(connections);
} catch (error) {
console.error("Failed to load REST API connections:", error);
setRestApiConnections([]);
}
};
loadRestApiConnections();
}, [open]);
// 외부 DB 테이블 목록 로드
useEffect(() => {
if (selectedDbSource === "internal" || !selectedDbSource) {
@@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}, [open, screenCode]);
const isValid = useMemo(() => {
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
}, [screenName, screenCode, tableName]);
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
if (dataSourceType === "database") {
return baseValid && tableName.trim().length > 0;
} else {
// REST API: 연결 선택 필수
return baseValid && selectedRestApiId !== null;
}
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
// 테이블 필터링 (내부 DB용)
const filteredTables = useMemo(() => {
@@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
setSubmitting(true);
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
// DB 소스 정보 추가
const created = await screenApi.createScreen({
// 데이터 소스 타입에 따라 다른 정보 전달
const createData: any = {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
tableName: tableName.trim(),
companyCode,
description: description.trim() || undefined,
createdBy: (user as any)?.userId,
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
} as any);
dataSourceType: dataSourceType,
};
if (dataSourceType === "database") {
// 데이터베이스 소스
createData.tableName = tableName.trim();
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
} else {
// REST API 소스
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
createData.restApiConnectionId = selectedRestApiId;
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
}
const created = await screenApi.createScreen(createData);
// 날짜 필드 보정
const mapped: ScreenDefinition = {
@@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
onCreated?.(mapped);
onOpenChange(false);
// 폼 초기화
setScreenName("");
setScreenCode("");
setTableName("");
setDescription("");
setSelectedDbSource("internal");
setDataSourceType("database");
setSelectedRestApiId(null);
setRestApiEndpoint("");
setRestApiJsonPath("data");
} catch (e) {
// 필요 시 토스트 추가 가능
} finally {
@@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
/>
</div>
{/* DB 소스 선택 */}
{/* 데이터 소스 타입 선택 */}
<div className="space-y-2">
<Label htmlFor="dbSource"> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDbSourceCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{selectedDbSource === "internal"
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
"선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="데이터베이스 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="internal"
onSelect={() => {
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
<Label> </Label>
<div className="flex gap-2">
<Button
type="button"
variant={dataSourceType === "database" ? "default" : "outline"}
className="flex-1"
onClick={() => {
setDataSourceType("database");
setSelectedRestApiId(null);
}}
>
<Database className="mr-2 h-4 w-4" />
</Button>
<Button
type="button"
variant={dataSourceType === "restapi" ? "default" : "outline"}
className="flex-1"
onClick={() => {
setDataSourceType("restapi");
setTableName("");
setSelectedDbSource("internal");
}}
>
<Globe className="mr-2 h-4 w-4" />
REST API
</Button>
</div>
</div>
{/* 테이블 선택 */}
{/* 데이터베이스 소스 설정 */}
{dataSourceType === "database" && (
<>
{/* DB 소스 선택 */}
<div className="space-y-2">
<Label htmlFor="dbSource"> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDbSourceCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{selectedDbSource === "internal"
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
"선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="데이터베이스 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="internal"
onSelect={() => {
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
</div>
</>
)}
{/* REST API 소스 설정 */}
{dataSourceType === "restapi" && (
<>
{/* REST API 연결 선택 */}
<div className="space-y-2">
<Label htmlFor="restApiConnection">REST API *</Label>
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openRestApiCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{selectedRestApiId
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
"선택하세요"
: "REST API 연결을 선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="REST API 검색..." />
<CommandList>
<CommandEmpty> REST API .</CommandEmpty>
<CommandGroup>
{restApiConnections.map((conn) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.base_url}`}
onSelect={() => {
setSelectedRestApiId(conn.id!);
setRestApiEndpoint(conn.endpoint_path || "");
setOpenRestApiCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
/>
<Globe className="mr-2 h-4 w-4 text-purple-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500">
REST API .
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
</Link>
</p>
</div>
{/* 엔드포인트 경로 */}
<div className="space-y-2">
<Label htmlFor="restApiEndpoint"> </Label>
<Input
id="restApiEndpoint"
value={restApiEndpoint}
onChange={(e) => setRestApiEndpoint(e.target.value)}
placeholder="/api/data 또는 /users"
/>
<p className="text-xs text-gray-500"> URL ()</p>
</div>
{/* JSON Path */}
<div className="space-y-2">
<Label htmlFor="restApiJsonPath"> (JSON Path)</Label>
<Input
id="restApiJsonPath"
value={restApiJsonPath}
onChange={(e) => setRestApiJsonPath(e.target.value)}
placeholder="data 또는 result.items"
/>
<p className="text-xs text-gray-500">API (: data, result.items)</p>
</div>
</>
)}
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
{dataSourceType === "database" && (
<div className="space-y-2">
<Label htmlFor="tableName"></Label>
<Label htmlFor="tableName"> *</Label>
<Select
value={tableName}
onValueChange={setTableName}
@@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
)}
</div>
<ResizableDialogFooter className="mt-4">

View File

@@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
value: currentValue,
onChange: (value: any) => handleFormDataChange(fieldName, value),
onFormDataChange: handleFormDataChange,
formData: formData, // 🆕 전체 formData 전달
isInteractive: true,
readonly: readonly,
required: required,
@@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
className: "w-full h-full",
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
}}
config={widget.webTypeConfig}
onEvent={(event: string, data: any) => {

View File

@@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => {
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
@@ -834,9 +835,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, []);
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
// 화면의 기본 테이블/REST API 정보 로드
useEffect(() => {
const loadScreenTable = async () => {
const loadScreenDataSource = async () => {
// REST API 데이터 소스인 경우
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
try {
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
selectedScreen.restApiConnectionId,
selectedScreen.restApiEndpoint,
selectedScreen.restApiJsonPath || "data",
);
// REST API 응답에서 컬럼 정보 생성
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
webType: col.dataType === "number" ? "number" : "text",
input_type: "text",
widgetType: col.dataType === "number" ? "number" : "text",
isNullable: "YES",
required: false,
}));
const tableInfo: TableInfo = {
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
columns,
};
setTables([tableInfo]);
console.log("REST API 데이터 소스 로드 완료:", {
connectionName: restApiData.connectionInfo.connectionName,
columnsCount: columns.length,
rowsCount: restApiData.total,
});
} catch (error) {
console.error("REST API 데이터 소스 로드 실패:", error);
toast.error("REST API 데이터를 불러오는데 실패했습니다.");
setTables([]);
}
return;
}
// 데이터베이스 데이터 소스인 경우 (기존 로직)
const tableName = selectedScreen?.tableName;
if (!tableName) {
setTables([]);
@@ -858,16 +902,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// 🔍 이미지 타입 디버깅
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
// console.log("🖼️ 이미지 컬럼 발견:", {
// columnName: col.columnName || col.column_name,
// widgetType,
// webType: col.webType || col.web_type,
// rawData: col,
// });
// }
return {
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
@@ -898,8 +932,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
};
loadScreenTable();
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
loadScreenDataSource();
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
// 화면 레이아웃 로드
useEffect(() => {

View File

@@ -869,27 +869,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
});
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return <ConfigPanelWrapper key={selectedComponent.id} />;
return (
<div className="space-y-4" key={selectedComponent.id}>
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
} else {
console.warn("⚠️ ConfigPanel 없음:", {
componentId,

View File

@@ -114,7 +114,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}) => {
const { webTypes } = useWebTypes({ active: "Y" });
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
const [localHeight, setLocalHeight] = useState<string>("");
const [localWidth, setLocalWidth] = useState<string>("");
@@ -147,7 +147,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}
}
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
// 높이 값 동기화
useEffect(() => {
if (selectedComponent?.size?.height !== undefined) {
@@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
? Math.floor(
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
(MIN_COLUMN_WIDTH + gridSettings.gap),
)
: 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
@@ -189,7 +192,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Grid3X3 className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<div className="space-y-3">
{/* 토글들 */}
<div className="flex items-center justify-between">
@@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{/* 10px 단위 스냅 안내 */}
<div className="bg-muted/50 rounded-md p-2">
<p className="text-[10px] text-muted-foreground">
10px .
</p>
<p className="text-muted-foreground text-[10px]"> 10px .</p>
</div>
</div>
</div>
@@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
if (!selectedComponent) {
return (
<div className="flex h-full flex-col bg-white">
<div className="flex h-full flex-col overflow-x-auto bg-white">
{/* 해상도 설정과 격자 설정 표시 */}
<div className="flex-1 overflow-y-auto p-2">
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs">
{/* 해상도 설정 */}
{currentResolution && onResolutionChange && (
@@ -287,9 +288,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
if (!selectedComponent) return null;
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
const componentType =
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
selectedComponent.componentConfig?.type ||
const componentType =
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id ||
selectedComponent.type;
@@ -305,15 +306,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId =
selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type ||
const componentId =
selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id ||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
@@ -327,12 +328,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handlePanelConfigChange = (newConfig: any) => {
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
const mergedConfig = {
...currentConfig, // 기존 설정 유지
...newConfig, // 새 설정 병합
...currentConfig, // 기존 설정 유지
...newConfig, // 새 설정 병합
};
console.log("🔧 [ConfigPanel] handleConfigChange:", {
componentId: selectedComponent.id,
@@ -346,16 +347,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
return (
<div key={selectedComponent.id} className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<Settings className="text-primary h-4 w-4" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<Suspense fallback={
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
}>
<ConfigPanelComponent
config={config}
<Suspense
fallback={
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
}
>
<ConfigPanelComponent
config={config}
onChange={handlePanelConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
@@ -423,9 +426,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Card </h3>
<p className="text-xs text-muted-foreground">
</p>
<p className="text-muted-foreground text-xs"> </p>
</div>
{/* 헤더 표시 */}
@@ -437,7 +438,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
}}
/>
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
<Label htmlFor="showHeader" className="cursor-pointer text-xs">
</Label>
</div>
@@ -467,7 +468,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
}}
placeholder="섹션 설명 입력"
className="text-xs resize-none"
className="resize-none text-xs"
rows={2}
/>
</div>
@@ -535,7 +536,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
{/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t">
<div className="space-y-2 border-t pt-2">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
@@ -544,13 +545,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
}}
/>
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
<Label htmlFor="collapsible" className="cursor-pointer text-xs">
/
</Label>
</div>
{selectedComponent.componentConfig?.collapsible && (
<div className="flex items-center space-x-2 ml-6">
<div className="ml-6 flex items-center space-x-2">
<Checkbox
id="defaultOpen"
checked={selectedComponent.componentConfig?.defaultOpen !== false}
@@ -558,7 +559,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
}}
/>
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
<Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
</Label>
</div>
@@ -572,9 +573,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Paper </h3>
<p className="text-xs text-muted-foreground">
</p>
<p className="text-muted-foreground text-xs"> </p>
</div>
{/* 배경색 */}
@@ -685,7 +684,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
}}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
<Label htmlFor="showBorder" className="cursor-pointer text-xs">
</Label>
</div>
@@ -696,9 +695,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// ConfigPanel이 없는 경우 경고 표시
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
<Settings className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="mb-2 text-base font-medium"> </h3>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
"{componentId || componentType}" .
</p>
</div>
@@ -1423,7 +1422,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
{/* 통합 컨텐츠 (탭 제거) */}
<div className="flex-1 overflow-y-auto p-2">
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs">
{/* 해상도 설정 - 항상 맨 위에 표시 */}
{currentResolution && onResolutionChange && (