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-18 16:15:57 +09:00
commit 5f026e88ab
19 changed files with 1163 additions and 68 deletions

View File

@ -17,6 +17,15 @@ export async function searchEntity(req: Request, res: Response) {
limit = "20",
} = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
return res.status(400).json({
success: false,
message: "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
});
}
// 멀티테넌시
const companyCode = req.user!.companyCode;

View File

@ -16,6 +16,7 @@ import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
interface ScreenModalState {
isOpen: boolean;
@ -55,11 +56,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>({});
// 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록)
const continuousModeRef = useRef(false);
const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음)
// localStorage에서 연속 모드 상태 복원
useEffect(() => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
@ -121,7 +122,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, description, size, urlParams } = event.detail;
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
@ -132,7 +133,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.history.pushState({}, "", currentUrl.toString());
console.log("✅ URL 파라미터 추가:", urlParams);
}
setModalState({
isOpen: true,
screenId,
@ -151,7 +152,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.history.pushState({}, "", currentUrl.toString());
console.log("🧹 URL 파라미터 제거");
}
setModalState({
isOpen: false,
screenId: null,
@ -172,14 +173,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// console.log("💾 저장 성공 이벤트 수신");
// console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode);
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
// console.log("✅ 연속 모드 활성화 - 폼만 초기화");
// 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨)
setFormData({});
toast.success("저장되었습니다. 계속 입력하세요.");
} else {
// 일반 모드: 모달 닫기
@ -226,7 +227,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면 관리에서 설정한 해상도 사용 (우선순위)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) {
// 화면 관리에서 설정한 해상도 사용
@ -242,7 +243,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
dimensions = calculateScreenDimensions(components);
console.log("⚠️ 자동 계산된 크기 사용:", dimensions);
}
setScreenDimensions(dimensions);
setScreenData({
@ -302,17 +303,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
const modalStyle = getModalStyle();
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
// modalId 생성 및 업데이트
useEffect(() => {
// 모달이 열려있고 screenId가 있을 때만 업데이트
if (!modalState.isOpen) return;
let newModalId: string | undefined;
// 1순위: screenId (가장 안정적)
if (modalState.screenId) {
newModalId = `screen-modal-${modalState.screenId}`;
@ -350,11 +351,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// result: newModalId,
// });
}
if (newModalId) {
setPersistedModalId(newModalId);
}
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
}, [
modalState.isOpen,
modalState.screenId,
modalState.title,
screenData?.screenInfo?.tableName,
screenData?.screenInfo?.screenName,
]);
return (
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
@ -397,7 +404,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
) : screenData ? (
<TableOptionsProvider>
<div
className="relative bg-white mx-auto"
className="relative mx-auto bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
@ -410,14 +417,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const offsetY = screenDimensions?.offsetY || 0;
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
const adjustedComponent =
offsetX === 0 && offsetY === 0
? component
: {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return (
<InteractiveScreenViewerDynamic
@ -472,10 +482,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// console.log("🔄 연속 모드 변경:", isChecked);
}}
/>
<Label
htmlFor="continuous-mode"
className="text-sm font-normal cursor-pointer select-none"
>
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>

View File

@ -272,19 +272,15 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
right: undefined,
};
// 디버깅: 크기 정보 로그
if (component.id && isSelected) {
console.log("📐 RealtimePreview baseStyle:", {
componentId: component.id,
componentType: (component as any).componentType || component.type,
sizeWidth: size?.width,
sizeHeight: size?.height,
styleWidth: componentStyle?.width,
styleHeight: componentStyle?.height,
baseStyleWidth: baseStyle.width,
baseStyleHeight: baseStyle.height,
});
}
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
// if (component.id && isSelected) {
// console.log("📐 RealtimePreview baseStyle:", {
// componentId: component.id,
// componentType: (component as any).componentType || component.type,
// sizeWidth: size?.width,
// sizeHeight: size?.height,
// });
// }
// 🔍 DOM 렌더링 후 실제 크기 측정
const innerDivRef = React.useRef<HTMLDivElement>(null);

View File

@ -883,10 +883,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
const config = currentConfig.config || definition.defaultConfig || {};
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
@ -895,7 +897,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onConfigChange={handleConfigChange} />
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
};

View File

@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
import {
@ -266,7 +267,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const renderComponentConfigPanel = () => {
if (!selectedComponent) return null;
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
const componentType =
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id ||
selectedComponent.type;
const handleUpdateProperty = (path: string, value: any) => {
onUpdateProperty(selectedComponent.id, path, value);
@ -276,10 +282,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId =
selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id;
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
@ -293,10 +304,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
const config = currentConfig.config || definition.defaultConfig || {};
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
@ -305,18 +318,19 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onConfigChange={handleConfigChange} />
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
};
return <ConfigPanelWrapper key={selectedComponent.id} />;
} else {
console.warn("⚠️ ConfigPanel 없음:", {
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
componentId,
definitionName: definition?.name,
hasDefinition: !!definition,
});
// ConfigPanel이 없으면 아래 switch case로 넘어감
}
}
@ -363,6 +377,280 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
case "badge-status":
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "section-card":
return (
<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>
</div>
{/* 헤더 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={selectedComponent.componentConfig?.showHeader !== false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
}}
/>
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
</Label>
</div>
{/* 제목 */}
{selectedComponent.componentConfig?.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.componentConfig?.title || ""}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.title", e.target.value);
}}
placeholder="섹션 제목 입력"
className="h-9 text-xs"
/>
</div>
)}
{/* 설명 */}
{selectedComponent.componentConfig?.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Textarea
value={selectedComponent.componentConfig?.description || ""}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
}}
placeholder="섹션 설명 입력"
className="text-xs resize-none"
rows={2}
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.padding || "md"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (24px)</SelectItem>
<SelectItem value="lg"> (32px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.componentConfig?.backgroundColor || "default"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ()</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="transparent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.borderStyle || "solid"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.borderStyle", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={selectedComponent.componentConfig?.collapsible || false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
}}
/>
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
/
</Label>
</div>
{selectedComponent.componentConfig?.collapsible && (
<div className="flex items-center space-x-2 ml-6">
<Checkbox
id="defaultOpen"
checked={selectedComponent.componentConfig?.defaultOpen !== false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
}}
/>
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
</Label>
</div>
)}
</div>
</div>
);
case "section-paper":
return (
<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>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.componentConfig?.backgroundColor || "default"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ( )</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="accent"> ( )</SelectItem>
<SelectItem value="primary"> </SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 커스텀 색상 */}
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.componentConfig?.customColor || "#f0f0f0"}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", e.target.value);
}}
className="h-9"
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.padding || "md"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (16px)</SelectItem>
<SelectItem value="lg"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 둥근 모서리 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.roundedCorners || "md"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.roundedCorners", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (2px)</SelectItem>
<SelectItem value="md"> (6px)</SelectItem>
<SelectItem value="lg"> (8px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그림자 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.componentConfig?.shadow || "none"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.shadow", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showBorder"
checked={selectedComponent.componentConfig?.showBorder || false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
}}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);
default:
// ConfigPanel이 없는 경우 경고 표시
return (
@ -634,11 +922,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
const renderDetailTab = () => {
console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type);
// 1. DataTable 컴포넌트
if (selectedComponent.type === "datatable") {
console.log("✅ [renderDetailTab] DataTable 컴포넌트");
return (
<DataTableConfigPanel
component={selectedComponent as DataTableComponent}
@ -695,7 +980,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 5. 새로운 컴포넌트 시스템 (type: "component")
if (selectedComponent.type === "component") {
console.log("✅ [renderDetailTab] Component 타입");
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
const webType = selectedComponent.componentConfig?.webType;
@ -755,7 +1039,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
onChange={(newConfig) => {
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => {
handleUpdate(`componentConfig.${key}`, value);

View File

@ -79,14 +79,14 @@ export const CategoryValueAddDialog: React.FC<
const valueCode = generateCode(valueLabel);
onAdd({
tableName: "",
columnName: "",
tableName: "", // CategoryValueManager에서 오버라이드됨
columnName: "", // CategoryValueManager에서 오버라이드됨
valueCode,
valueLabel: valueLabel.trim(),
description: description.trim(),
color: color,
description: description.trim() || undefined, // 빈 문자열 대신 undefined
color: color === "none" ? undefined : color, // "none"은 undefined로
isDefault: false,
});
} as TableCategoryValue);
// 초기화
setValueLabel("");

View File

@ -308,6 +308,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
style: finalStyle, // size를 포함한 최종 style
config: component.componentConfig,
componentConfig: component.componentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}),
value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달
autoGeneration: component.autoGeneration || component.componentConfig?.autoGeneration,

View File

@ -155,14 +155,16 @@ export function EntitySearchModal({
</td>
</tr>
) : (
results.map((item, index) => (
results.map((item, index) => {
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
return (
<tr
key={item[valueField] || index}
key={uniqueKey}
className="border-t hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleSelect(item)}
>
{displayColumns.map((col, colIndex) => (
<td key={`${item[valueField] || index}-${col}-${colIndex}`} className="px-4 py-2">
{displayColumns.map((col) => (
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
{item[col] || "-"}
</td>
))}
@ -180,7 +182,8 @@ export function EntitySearchModal({
</Button>
</td>
</tr>
))
);
})
)}
</tbody>
</table>

View File

@ -34,6 +34,13 @@ export function useEntitySearch({
const search = useCallback(
async (text: string, page: number = 1) => {
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
console.warn("엔티티 검색 건너뜀: tableName이 없음", { tableName });
setError("테이블명이 설정되지 않았습니다. 컴포넌트 설정을 확인해주세요.");
return;
}
try {
setLoading(true);
setError(null);
@ -60,7 +67,8 @@ export function useEntitySearch({
}
} catch (err: any) {
console.error("Entity search error:", err);
setError(err.response?.data?.message || "검색 중 오류가 발생했습니다");
const errorMessage = err.response?.data?.message || "검색 중 오류가 발생했습니다";
setError(errorMessage);
} finally {
setLoading(false);
}

View File

@ -55,6 +55,10 @@ import "./order-registration-modal/OrderRegistrationModalRenderer";
import "./conditional-container/ConditionalContainerRenderer";
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
// 🆕 섹션 그룹화 레이아웃 컴포넌트
import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
/**
*
*/

View File

@ -0,0 +1,178 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export interface SectionCardProps {
component?: {
id: string;
componentConfig?: {
title?: string;
description?: string;
showHeader?: boolean;
headerPosition?: "top" | "left";
padding?: "none" | "sm" | "md" | "lg";
backgroundColor?: "default" | "muted" | "transparent";
borderStyle?: "solid" | "dashed" | "none";
collapsible?: boolean;
defaultOpen?: boolean;
};
style?: React.CSSProperties;
};
children?: React.ReactNode;
className?: string;
onClick?: (e?: React.MouseEvent) => void;
isSelected?: boolean;
isDesignMode?: boolean;
}
/**
* Section Card
*
*/
export function SectionCardComponent({
component,
children,
className,
onClick,
isSelected = false,
isDesignMode = false,
}: SectionCardProps) {
const config = component?.componentConfig || {};
const [isOpen, setIsOpen] = React.useState(config.defaultOpen !== false);
// 🔄 실시간 업데이트를 위해 config에서 직접 읽기
const title = config.title || "";
const description = config.description || "";
const showHeader = config.showHeader !== false; // 기본값: true
const padding = config.padding || "md";
const backgroundColor = config.backgroundColor || "default";
const borderStyle = config.borderStyle || "solid";
const collapsible = config.collapsible || false;
// 🎯 디버깅: config 값 확인
React.useEffect(() => {
console.log("✅ Section Card Config:", {
title,
description,
showHeader,
fullConfig: config,
});
}, [config.title, config.description, config.showHeader]);
// 패딩 매핑
const paddingMap = {
none: "p-0",
sm: "p-3",
md: "p-6",
lg: "p-8",
};
// 배경색 매핑
const backgroundColorMap = {
default: "bg-card",
muted: "bg-muted/30",
transparent: "bg-transparent",
};
// 테두리 스타일 매핑
const borderStyleMap = {
solid: "border-solid",
dashed: "border-dashed",
none: "border-none",
};
const handleToggle = () => {
if (collapsible) {
setIsOpen(!isOpen);
}
};
return (
<Card
className={cn(
"transition-all",
backgroundColorMap[backgroundColor],
borderStyleMap[borderStyle],
borderStyle === "none" && "shadow-none",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
isDesignMode && !children && "min-h-[150px]",
className
)}
style={component?.style}
onClick={onClick}
>
{/* 헤더 */}
{showHeader && (title || description || isDesignMode) && (
<CardHeader
className={cn(
"cursor-pointer",
collapsible && "hover:bg-accent/50 transition-colors"
)}
onClick={handleToggle}
>
<div className="flex items-center justify-between">
<div className="flex-1">
{(title || isDesignMode) && (
<CardTitle className="text-xl font-semibold">
{title || (isDesignMode ? "섹션 제목" : "")}
</CardTitle>
)}
{(description || isDesignMode) && (
<CardDescription className="text-sm text-muted-foreground mt-1.5">
{description || (isDesignMode ? "섹션 설명 (선택사항)" : "")}
</CardDescription>
)}
</div>
{collapsible && (
<div className={cn(
"ml-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)}
</div>
</CardHeader>
)}
{/* 컨텐츠 */}
{(!collapsible || isOpen) && (
<CardContent className={cn(paddingMap[padding])}>
{/* 디자인 모드에서 빈 상태 안내 */}
{isDesignMode && !children && (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<div className="text-center">
<div className="mb-2">🃏 Section Card</div>
<div className="text-xs"> </div>
</div>
</div>
)}
{/* 자식 컴포넌트들 */}
{children}
</CardContent>
)}
</Card>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
interface SectionCardConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export function SectionCardConfigPanel({
config,
onChange,
}: SectionCardConfigPanelProps) {
const handleChange = (key: string, value: any) => {
const newConfig = {
...config,
[key]: value,
};
onChange(newConfig);
// 🎯 실시간 업데이트를 위한 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
detail: { config: newConfig }
}));
}
};
return (
<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>
</div>
{/* 헤더 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={config.showHeader !== false}
onCheckedChange={(checked) => handleChange("showHeader", checked)}
/>
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
</Label>
</div>
{/* 제목 */}
{config.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || ""}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="섹션 제목 입력"
className="h-9 text-xs"
/>
</div>
)}
{/* 설명 */}
{config.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Textarea
value={config.description || ""}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="섹션 설명 입력"
className="text-xs resize-none"
rows={2}
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.padding || "md"}
onValueChange={(value) => handleChange("padding", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (24px)</SelectItem>
<SelectItem value="lg"> (32px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.backgroundColor || "default"}
onValueChange={(value) => handleChange("backgroundColor", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ()</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="transparent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.borderStyle || "solid"}
onValueChange={(value) => handleChange("borderStyle", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible || false}
onCheckedChange={(checked) => handleChange("collapsible", checked)}
/>
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
/
</Label>
</div>
{config.collapsible && (
<div className="flex items-center space-x-2 ml-6">
<Checkbox
id="defaultOpen"
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => handleChange("defaultOpen", checked)}
/>
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
</Label>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SectionCardDefinition } from "./index";
import { SectionCardComponent } from "./SectionCardComponent";
/**
* Section Card
*
*/
export class SectionCardRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SectionCardDefinition;
render(): React.ReactElement {
return <SectionCardComponent {...this.props} renderer={this} />;
}
}
// 자동 등록 실행
SectionCardRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SectionCardRenderer.enableHotReload();
}

View File

@ -0,0 +1,43 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionCardComponent } from "./SectionCardComponent";
import { SectionCardConfigPanel } from "./SectionCardConfigPanel";
/**
* Section Card
*
*/
export const SectionCardDefinition = createComponentDefinition({
id: "section-card",
name: "Section Card",
nameEng: "Section Card",
description: "제목과 테두리가 있는 명확한 그룹화 컨테이너",
category: ComponentCategory.LAYOUT,
webType: "custom",
component: SectionCardComponent,
defaultConfig: {
title: "섹션 제목",
description: "",
showHeader: true,
padding: "md",
backgroundColor: "default",
borderStyle: "solid",
collapsible: false,
defaultOpen: true,
},
defaultSize: { width: 800, height: 250 },
configPanel: SectionCardConfigPanel,
icon: "LayoutPanelTop",
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
version: "1.0.0",
author: "WACE",
});
// 컴포넌트는 SectionCardRenderer에서 자동 등록됩니다
export { SectionCardComponent } from "./SectionCardComponent";
export { SectionCardConfigPanel } from "./SectionCardConfigPanel";
export { SectionCardRenderer } from "./SectionCardRenderer";

View File

@ -0,0 +1,138 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export interface SectionPaperProps {
component?: {
id: string;
componentConfig?: {
backgroundColor?: "default" | "muted" | "accent" | "primary" | "custom";
customColor?: string;
showBorder?: boolean;
borderStyle?: "none" | "subtle";
padding?: "none" | "sm" | "md" | "lg";
roundedCorners?: "none" | "sm" | "md" | "lg";
shadow?: "none" | "sm" | "md";
};
style?: React.CSSProperties;
};
children?: React.ReactNode;
className?: string;
onClick?: (e?: React.MouseEvent) => void;
isSelected?: boolean;
isDesignMode?: boolean;
}
/**
* Section Paper
* ( )
*/
export function SectionPaperComponent({
component,
children,
className,
onClick,
isSelected = false,
isDesignMode = false,
}: SectionPaperProps) {
const config = component?.componentConfig || {};
// 배경색 매핑
const backgroundColorMap = {
default: "bg-muted/20",
muted: "bg-muted/30",
accent: "bg-accent/20",
primary: "bg-primary/5",
custom: "",
};
// 패딩 매핑
const paddingMap = {
none: "p-0",
sm: "p-3",
md: "p-4",
lg: "p-6",
};
// 둥근 모서리 매핑
const roundedMap = {
none: "rounded-none",
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
};
// 그림자 매핑
const shadowMap = {
none: "",
sm: "shadow-sm",
md: "shadow-md",
};
const backgroundColor = config.backgroundColor || "default";
const padding = config.padding || "md";
const rounded = config.roundedCorners || "md";
const shadow = config.shadow || "none";
const showBorder = config.showBorder || false;
const borderStyle = config.borderStyle || "subtle";
// 커스텀 배경색 처리
const customBgStyle =
backgroundColor === "custom" && config.customColor
? { backgroundColor: config.customColor }
: {};
return (
<div
className={cn(
// 기본 스타일
"relative transition-colors",
// 배경색
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
// 패딩
paddingMap[padding],
// 둥근 모서리
roundedMap[rounded],
// 그림자
shadowMap[shadow],
// 테두리 (선택)
showBorder &&
borderStyle === "subtle" &&
"border border-border/30",
// 디자인 모드에서 선택된 상태
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
// 디자인 모드에서 빈 상태 표시
isDesignMode && !children && "min-h-[100px] border-2 border-dashed border-muted-foreground/30",
className
)}
style={{
...customBgStyle,
...component?.style,
}}
onClick={onClick}
>
{/* 디자인 모드에서 빈 상태 안내 */}
{isDesignMode && !children && (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
<div className="text-center">
<div className="mb-1">📄 Section Paper</div>
<div className="text-xs"> </div>
</div>
</div>
)}
{/* 자식 컴포넌트들 */}
{children}
</div>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
interface SectionPaperConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export function SectionPaperConfigPanel({
config,
onChange,
}: SectionPaperConfigPanelProps) {
const handleChange = (key: string, value: any) => {
const newConfig = {
...config,
[key]: value,
};
onChange(newConfig);
// 🎯 실시간 업데이트를 위한 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
detail: { config: newConfig }
}));
}
};
return (
<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>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.backgroundColor || "default"}
onValueChange={(value) => handleChange("backgroundColor", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ( )</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="accent"> ( )</SelectItem>
<SelectItem value="primary"> </SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 커스텀 색상 */}
{config.backgroundColor === "custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.customColor || "#f0f0f0"}
onChange={(e) => handleChange("customColor", e.target.value)}
className="h-9"
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.padding || "md"}
onValueChange={(value) => handleChange("padding", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (16px)</SelectItem>
<SelectItem value="lg"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 둥근 모서리 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.roundedCorners || "md"}
onValueChange={(value) => handleChange("roundedCorners", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (2px)</SelectItem>
<SelectItem value="md"> (6px)</SelectItem>
<SelectItem value="lg"> (8px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그림자 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.shadow || "none"}
onValueChange={(value) => handleChange("shadow", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showBorder"
checked={config.showBorder || false}
onCheckedChange={(checked) => handleChange("showBorder", checked)}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SectionPaperDefinition } from "./index";
import { SectionPaperComponent } from "./SectionPaperComponent";
/**
* Section Paper
*
*/
export class SectionPaperRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SectionPaperDefinition;
render(): React.ReactElement {
return <SectionPaperComponent {...this.props} renderer={this} />;
}
}
// 자동 등록 실행
SectionPaperRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SectionPaperRenderer.enableHotReload();
}

View File

@ -0,0 +1,40 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionPaperComponent } from "./SectionPaperComponent";
import { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
/**
* Section Paper
* ( )
*/
export const SectionPaperDefinition = createComponentDefinition({
id: "section-paper",
name: "Section Paper",
nameEng: "Section Paper",
description: "배경색 기반의 미니멀한 그룹화 컨테이너 (색종이 컨셉)",
category: ComponentCategory.LAYOUT,
webType: "custom",
component: SectionPaperComponent,
defaultConfig: {
backgroundColor: "default",
padding: "md",
roundedCorners: "md",
shadow: "none",
showBorder: false,
},
defaultSize: { width: 800, height: 200 },
configPanel: SectionPaperConfigPanel,
icon: "Square",
tags: ["섹션", "그룹", "배경", "컨테이너", "색종이", "paper"],
version: "1.0.0",
author: "WACE",
});
// 컴포넌트는 SectionPaperRenderer에서 자동 등록됩니다
export { SectionPaperComponent } from "./SectionPaperComponent";
export { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
export { SectionPaperRenderer } from "./SectionPaperRenderer";

View File

@ -39,6 +39,9 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// 🆕 선택 항목 상세입력
"selected-items-detail-input": () =>
import("@/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel"),
// 🆕 섹션 그룹화 레이아웃
"section-card": () => import("@/lib/registry/components/section-card/SectionCardConfigPanel"),
"section-paper": () => import("@/lib/registry/components/section-paper/SectionPaperConfigPanel"),
};
// ConfigPanel 컴포넌트 캐시
@ -71,6 +74,8 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
module.SelectedItemsDetailInputConfigPanel || // selected-items-detail-input의 export명
module.ButtonConfigPanel || // button-primary의 export명
module.SectionCardConfigPanel || // section-card의 export명
module.SectionPaperConfigPanel || // section-paper의 export명
module.default;
if (!ConfigPanelComponent) {