Compare commits

...

2 Commits

20 changed files with 1321 additions and 10447 deletions

View File

@ -1044,7 +1044,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -2372,7 +2371,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3476,7 +3474,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3713,7 +3710,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3931,7 +3927,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4458,7 +4453,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -5669,7 +5663,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -7432,7 +7425,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8402,6 +8394,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -9290,7 +9283,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -10141,6 +10133,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10949,7 +10942,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11055,7 +11047,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -36,6 +36,9 @@ interface RealtimePreviewProps {
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
// 버튼 액션을 위한 props
screenId?: number;
@ -133,6 +136,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onFormDataChange,
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
conditionalDisabled, // 🆕 조건부 비활성화 상태
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
}) => {
// 🆕 화면 다국어 컨텍스트
const { getTranslatedText } = useScreenMultiLang();
@ -518,6 +524,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
columnOrder={columnOrder}
onHeightChange={onHeightChange}
conditionalDisabled={conditionalDisabled}
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
/>
</div>

View File

@ -164,10 +164,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 🆕 탭 내부 컴포넌트 선택 상태
const [selectedTabComponentInfo, setSelectedTabComponentInfo] = useState<{
tabsComponentId: string; // 탭 컴포넌트 ID
tabId: string; // 탭 ID
componentId: string; // 탭 내부 컴포넌트 ID
component: any; // 탭 내부 컴포넌트 데이터
} | null>(null);
// 컴포넌트 선택 시 통합 패널 자동 열기
const handleComponentSelect = useCallback(
(component: ComponentData | null) => {
setSelectedComponent(component);
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제
if (component) {
setSelectedTabComponentInfo(null);
}
// 컴포넌트가 선택되면 통합 패널 자동 열기
if (component) {
@ -177,6 +189,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[openPanel],
);
// 🆕 탭 내부 컴포넌트 선택 핸들러
const handleSelectTabComponent = useCallback(
(tabsComponentId: string, tabId: string, compId: string, comp: any) => {
if (!compId) {
// 탭 영역 빈 공간 클릭 시 선택 해제
setSelectedTabComponentInfo(null);
return;
}
setSelectedTabComponentInfo({
tabsComponentId,
tabId,
componentId: compId,
component: comp,
});
// 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제
setSelectedComponent(null);
openPanel("unified");
},
[openPanel],
);
// 클립보드 상태
const [clipboard, setClipboard] = useState<ComponentData[]>([]);
@ -380,6 +415,96 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[historyIndex],
);
// 🆕 탭 내부 컴포넌트 설정 업데이트 핸들러
const handleUpdateTabComponentConfig = useCallback(
(path: string, value: any) => {
if (!selectedTabComponentInfo) return;
const { tabsComponentId, tabId, componentId } = selectedTabComponentInfo;
setLayout((prevLayout) => {
const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId);
if (!tabsComponent) return prevLayout;
const currentConfig = (tabsComponent as any).componentConfig || {};
const tabs = currentConfig.tabs || [];
const updatedTabs = tabs.map((tab: any) => {
if (tab.id === tabId) {
return {
...tab,
components: (tab.components || []).map((comp: any) => {
if (comp.id === componentId) {
// path에 따라 적절한 속성 업데이트
if (path.startsWith("componentConfig.")) {
const configPath = path.replace("componentConfig.", "");
return {
...comp,
componentConfig: {
...comp.componentConfig,
[configPath]: value,
},
};
} else if (path.startsWith("style.")) {
const stylePath = path.replace("style.", "");
return {
...comp,
style: {
...comp.style,
[stylePath]: value,
},
};
} else if (path.startsWith("size.")) {
const sizePath = path.replace("size.", "");
return {
...comp,
size: {
...comp.size,
[sizePath]: value,
},
};
} else {
return { ...comp, [path]: value };
}
}
return comp;
}),
};
}
return tab;
});
const updatedComponent = {
...tabsComponent,
componentConfig: {
...currentConfig,
tabs: updatedTabs,
},
};
const newLayout = {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === tabsComponentId ? updatedComponent : c
),
};
// 선택된 컴포넌트 정보도 업데이트
const updatedComp = updatedTabs
.find((t: any) => t.id === tabId)
?.components?.find((c: any) => c.id === componentId);
if (updatedComp) {
setSelectedTabComponentInfo((prev) =>
prev ? { ...prev, component: updatedComp } : null
);
}
return newLayout;
});
},
[selectedTabComponentInfo],
);
// 실행취소
const undo = useCallback(() => {
setHistoryIndex((prevIndex) => {
@ -2271,6 +2396,67 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}
// 🎯 탭 컨테이너 내부 드롭 처리
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
if (tabsContainer) {
const containerId = tabsContainer.getAttribute("data-component-id");
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
if (containerId && activeTabId) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const tabs = currentConfig.tabs || [];
// 활성 탭의 드롭 위치 계산
const tabContentRect = tabsContainer.getBoundingClientRect();
const dropX = (e.clientX - tabContentRect.left) / zoomLevel;
const dropY = (e.clientY - tabContentRect.top) / zoomLevel;
// 새 컴포넌트 생성
const newTabComponent = {
id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: component.id || component.componentType || "text-display",
label: component.name || component.label || "새 컴포넌트",
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: component.defaultSize || { width: 200, height: 100 },
componentConfig: component.defaultConfig || {},
};
// 해당 탭에 컴포넌트 추가
const updatedTabs = tabs.map((tab: any) => {
if (tab.id === activeTabId) {
return {
...tab,
components: [...(tab.components || []), newTabComponent],
};
}
return tab;
});
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
tabs: updatedTabs,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success("컴포넌트가 탭에 추가되었습니다");
return; // 탭 컨테이너 처리 완료
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -2655,6 +2841,70 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
if (tabsContainer && type === "column" && column) {
const containerId = tabsContainer.getAttribute("data-component-id");
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
if (containerId && activeTabId) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const tabs = currentConfig.tabs || [];
// 드롭 위치 계산
const tabContentRect = tabsContainer.getBoundingClientRect();
const dropX = (e.clientX - tabContentRect.left) / zoomLevel;
const dropY = (e.clientY - tabContentRect.top) / zoomLevel;
// 새 컴포넌트 생성 (컬럼 기반)
const newTabComponent = {
id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: column.widgetType || "unified-input",
label: column.columnLabel || column.columnName,
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: { width: 200, height: 60 },
componentConfig: {
columnName: column.columnName,
tableName: column.tableName,
},
};
// 해당 탭에 컴포넌트 추가
const updatedTabs = tabs.map((tab: any) => {
if (tab.id === activeTabId) {
return {
...tab,
components: [...(tab.components || []), newTabComponent],
};
}
return tab;
});
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
tabs: updatedTabs,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success("컬럼이 탭에 추가되었습니다");
return;
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -4605,24 +4855,125 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
<UnifiedPropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
dragState={dragState}
onStyleChange={(style) => {
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
allComponents={layout.components} // 🆕 플로우 위젯 감지용
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
{/* 🆕 탭 내부 컴포넌트가 선택된 경우 별도 패널 표시 */}
{selectedTabComponentInfo ? (
<div className="flex h-full flex-col p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
{selectedTabComponentInfo.component.label || selectedTabComponentInfo.component.componentType}
</p>
</div>
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSelectedTabComponentInfo(null)}
>
</button>
</div>
{/* DynamicComponentConfigPanel 렌더링 */}
<div className="flex-1 overflow-auto">
{(() => {
const DynamicConfigPanel = require("@/lib/utils/getComponentConfigPanel").DynamicComponentConfigPanel;
const tabComp = selectedTabComponentInfo.component;
// 탭 내부 컴포넌트를 일반 컴포넌트 형식으로 변환
const componentForConfig = {
id: tabComp.id,
type: "component",
componentType: tabComp.componentType,
label: tabComp.label,
position: tabComp.position,
size: tabComp.size,
componentConfig: tabComp.componentConfig || {},
style: tabComp.style || {},
};
return (
<DynamicConfigPanel
componentId={tabComp.componentType}
component={componentForConfig}
config={tabComp.componentConfig || {}}
onChange={(newConfig: any) => {
// componentConfig 전체 업데이트
const { tabsComponentId, tabId, componentId } = selectedTabComponentInfo;
const tabsComponent = layout.components.find((c) => c.id === tabsComponentId);
if (!tabsComponent) return;
const currentConfig = (tabsComponent as any).componentConfig || {};
const tabs = currentConfig.tabs || [];
const updatedTabs = tabs.map((tab: any) => {
if (tab.id === tabId) {
return {
...tab,
components: (tab.components || []).map((comp: any) =>
comp.id === componentId
? { ...comp, componentConfig: newConfig }
: comp
),
};
}
return tab;
});
const updatedComponent = {
...tabsComponent,
componentConfig: {
...currentConfig,
tabs: updatedTabs,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === tabsComponentId ? updatedComponent : c
),
};
setLayout(newLayout);
saveToHistory(newLayout);
// 선택된 컴포넌트 정보 업데이트
const updatedComp = updatedTabs
.find((t: any) => t.id === tabId)
?.components?.find((c: any) => c.id === componentId);
if (updatedComp) {
setSelectedTabComponentInfo({
...selectedTabComponentInfo,
component: updatedComp,
});
}
}}
tables={tables}
allComponents={layout.components}
/>
);
})()}
</div>
</div>
) : (
<UnifiedPropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
dragState={dragState}
onStyleChange={(style) => {
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
allComponents={layout.components} // 🆕 플로우 위젯 감지용
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
)}
</TabsContent>
</Tabs>
</div>
@ -4952,6 +5303,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
updatedConfig: config,
});
}}
// 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등)
onUpdateComponent={(updatedComponent) => {
const updatedComponents = layout.components.map((comp) =>
comp.id === updatedComponent.id ? updatedComponent : comp
);
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
}}
// 🆕 탭 내부 컴포넌트 선택 핸들러
onSelectTabComponent={(tabId, compId, comp) =>
handleSelectTabComponent(component.id, tabId, compId, comp)
}
selectedTabComponentId={
selectedTabComponentInfo?.tabsComponentId === component.id
? selectedTabComponentInfo.componentId
: undefined
}
>
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" ||

View File

@ -5,70 +5,59 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
X,
GripVertical,
ChevronDown,
ChevronRight,
Trash2,
Move,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { TabItem, TabsComponent } from "@/types/screen-management";
import type { TabItem, TabInlineComponent } from "@/types/screen-management";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface TabsConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
interface ScreenInfo {
screenId: number;
screenName: string;
screenCode: string;
tableName: string;
}
export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
const [localTabs, setLocalTabs] = useState<TabItem[]>(config.tabs || []);
// 화면 목록 로드
useEffect(() => {
const loadScreens = async () => {
try {
setLoading(true);
// API 클라이언트 동적 import (named export 사용)
const { apiClient } = await import("@/lib/api/client");
// 전체 화면 목록 조회 (페이징 사이즈 크게)
const response = await apiClient.get("/screen-management/screens", {
params: { size: 1000 }
});
console.log("화면 목록 조회 성공:", response.data);
if (response.data.success && response.data.data) {
setScreens(response.data.data);
}
} catch (error: any) {
console.error("Failed to load screens:", error);
console.error("Error response:", error.response?.data);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
// 컴포넌트 변경 시 로컬 상태 동기화 (초기화만, 입력 중에는 동기화하지 않음)
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [isUserEditing, setIsUserEditing] = useState(false);
useEffect(() => {
// 사용자가 입력 중이 아닐 때만 동기화
if (!isUserEditing) {
setLocalTabs(config.tabs || []);
}
}, [config.tabs, isUserEditing]);
// 탭 확장/축소 토글
const toggleTabExpand = (tabId: string) => {
setExpandedTabs((prev) => {
const newSet = new Set(prev);
if (newSet.has(tabId)) {
newSet.delete(tabId);
} else {
newSet.add(tabId);
}
return newSet;
});
};
// 탭 추가
const handleAddTab = () => {
const newTab: TabItem = {
@ -76,11 +65,15 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
label: `새 탭 ${localTabs.length + 1}`,
order: localTabs.length,
disabled: false,
components: [],
};
const updatedTabs = [...localTabs, newTab];
setLocalTabs(updatedTabs);
onChange({ ...config, tabs: updatedTabs });
// 새 탭 자동 확장
setExpandedTabs((prev) => new Set([...prev, newTab.id]));
};
// 탭 제거
@ -93,27 +86,23 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
// 탭 라벨 변경 (입력 중)
const handleLabelChange = (tabId: string, label: string) => {
setIsUserEditing(true);
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab));
const updatedTabs = localTabs.map((tab) =>
tab.id === tabId ? { ...tab, label } : tab
);
setLocalTabs(updatedTabs);
// onChange는 onBlur에서 호출
};
// 탭 라벨 변경 완료 (포커스 아웃 시)
// 탭 라벨 변경 완료
const handleLabelBlur = () => {
setIsUserEditing(false);
onChange({ ...config, tabs: localTabs });
};
// 탭 화면 선택
const handleScreenSelect = (tabId: string, screenId: number, screenName: string) => {
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, screenId, screenName } : tab));
setLocalTabs(updatedTabs);
onChange({ ...config, tabs: updatedTabs });
};
// 탭 비활성화 토글
const handleDisabledToggle = (tabId: string, disabled: boolean) => {
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, disabled } : tab));
const updatedTabs = localTabs.map((tab) =>
tab.id === tabId ? { ...tab, disabled } : tab
);
setLocalTabs(updatedTabs);
onChange({ ...config, tabs: updatedTabs });
};
@ -130,14 +119,68 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
const newTabs = [...localTabs];
const targetIndex = direction === "up" ? index - 1 : index + 1;
[newTabs[index], newTabs[targetIndex]] = [newTabs[targetIndex], newTabs[index]];
[newTabs[index], newTabs[targetIndex]] = [
newTabs[targetIndex],
newTabs[index],
];
// order 값 재조정
const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx }));
setLocalTabs(updatedTabs);
onChange({ ...config, tabs: updatedTabs });
};
// 컴포넌트 제거
const handleRemoveComponent = (tabId: string, componentId: string) => {
const updatedTabs = localTabs.map((tab) => {
if (tab.id === tabId) {
return {
...tab,
components: (tab.components || []).filter(
(comp) => comp.id !== componentId
),
};
}
return tab;
});
setLocalTabs(updatedTabs);
onChange({ ...config, tabs: updatedTabs });
};
// 컴포넌트 위치 변경
const handleComponentPositionChange = (
tabId: string,
componentId: string,
field: "x" | "y" | "width" | "height",
value: number
) => {
const updatedTabs = localTabs.map((tab) => {
if (tab.id === tabId) {
return {
...tab,
components: (tab.components || []).map((comp) => {
if (comp.id === componentId) {
if (field === "x" || field === "y") {
return {
...comp,
position: { ...comp.position, [field]: value },
};
} else {
return {
...comp,
size: { ...comp.size, [field]: value },
};
}
}
return comp;
}),
};
}
return tab;
});
setLocalTabs(updatedTabs);
onChange({ ...config, tabs: updatedTabs });
};
return (
<div className="space-y-6 p-4">
<div>
@ -193,7 +236,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
</div>
<Switch
checked={config.persistSelection || false}
onCheckedChange={(checked) => onChange({ ...config, persistSelection: checked })}
onCheckedChange={(checked) =>
onChange({ ...config, persistSelection: checked })
}
/>
</div>
@ -207,7 +252,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
</div>
<Switch
checked={config.allowCloseable || false}
onCheckedChange={(checked) => onChange({ ...config, allowCloseable: checked })}
onCheckedChange={(checked) =>
onChange({ ...config, allowCloseable: checked })
}
/>
</div>
</div>
@ -237,168 +284,157 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
) : (
<div className="space-y-3">
{localTabs.map((tab, index) => (
<div
<Collapsible
key={tab.id}
className="rounded-lg border bg-card p-3 shadow-sm"
open={expandedTabs.has(tab.id)}
onOpenChange={() => toggleTabExpand(tab.id)}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium"> {index + 1}</span>
</div>
<div className="flex items-center gap-1">
<Button
onClick={() => handleMoveTab(tab.id, "up")}
disabled={index === 0}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
>
</Button>
<Button
onClick={() => handleMoveTab(tab.id, "down")}
disabled={index === localTabs.length - 1}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
>
</Button>
<Button
onClick={() => handleRemoveTab(tab.id)}
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-3">
{/* 탭 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tab.label}
onChange={(e) => handleLabelChange(tab.id, e.target.value)}
onBlur={handleLabelBlur}
placeholder="탭 이름"
className="h-8 text-xs sm:h-9 sm:text-sm"
/>
<div className="rounded-lg border bg-card shadow-sm">
{/* 탭 헤더 */}
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground" />
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
{expandedTabs.has(tab.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<span className="text-xs font-medium">
{tab.label || `${index + 1}`}
</span>
{tab.components && tab.components.length > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-primary">
{tab.components.length}
</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
onClick={() => handleMoveTab(tab.id, "up")}
disabled={index === 0}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
>
</Button>
<Button
onClick={() => handleMoveTab(tab.id, "down")}
disabled={index === localTabs.length - 1}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
>
</Button>
<Button
onClick={() => handleRemoveTab(tab.id)}
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* 화면 선택 */}
<div>
<Label className="text-xs"> </Label>
{loading ? (
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground text-xs"> ...</span>
{/* 탭 컨텐츠 */}
<CollapsibleContent>
<div className="space-y-4 border-t p-3">
{/* 탭 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tab.label}
onChange={(e) =>
handleLabelChange(tab.id, e.target.value)
}
onBlur={handleLabelBlur}
placeholder="탭 이름"
className="h-8 text-xs sm:h-9 sm:text-sm"
/>
</div>
) : (
<ScreenSelectCombobox
screens={screens}
selectedScreenId={tab.screenId}
onSelect={(screenId, screenName) =>
handleScreenSelect(tab.id, screenId, screenName)
}
/>
)}
{tab.screenName && (
<p className="text-muted-foreground mt-1 text-xs">
: {tab.screenName}
</p>
)}
</div>
{/* 비활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={tab.disabled || false}
onCheckedChange={(checked) => handleDisabledToggle(tab.id, checked)}
/>
</div>
{/* 비활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={tab.disabled || false}
onCheckedChange={(checked) =>
handleDisabledToggle(tab.id, checked)
}
/>
</div>
{/* 컴포넌트 목록 */}
<div>
<Label className="mb-2 block text-xs">
</Label>
{!tab.components || tab.components.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="space-y-2">
{tab.components.map((comp: TabInlineComponent) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-md border bg-background p-2"
>
<div className="flex-1">
<p className="text-xs font-medium">
{comp.label || comp.componentType}
</p>
<p className="text-muted-foreground text-[10px]">
{comp.componentType} | : ({comp.position?.x || 0},{" "}
{comp.position?.y || 0}) | : {comp.size?.width || 0}x
{comp.size?.height || 0}
</p>
</div>
<Button
onClick={() =>
handleRemoveComponent(tab.id, comp.id)
}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</CollapsibleContent>
</div>
</div>
</Collapsible>
))}
</div>
)}
</div>
{/* 사용 안내 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<h4 className="mb-1 text-xs font-semibold text-blue-900">
</h4>
<ol className="list-inside list-decimal space-y-1 text-[10px] text-blue-800">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ol>
</div>
</div>
);
}
// 화면 선택 Combobox 컴포넌트
function ScreenSelectCombobox({
screens,
selectedScreenId,
onSelect,
}: {
screens: ScreenInfo[];
selectedScreenId?: number;
onSelect: (screenId: number, screenName: string) => void;
}) {
const [open, setOpen] = useState(false);
const selectedScreen = screens.find((s) => s.screenId === selectedScreenId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
{selectedScreen ? selectedScreen.screenName : "화면 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={screen.screenName}
onSelect={() => {
onSelect(screen.screenId, screen.screenName);
setOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
selectedScreenId === screen.screenId ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-muted-foreground text-[10px]">
: {screen.screenCode} | : {screen.tableName}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -132,6 +132,7 @@ export function ComponentsPanel({
"pivot-grid", // → v2-pivot-grid
"table-search-widget", // → v2-table-search-widget
"tabs", // → v2-tabs
"tabs-widget", // → v2-tabs-widget
];
return {

View File

@ -1,23 +1,37 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { X, Loader2 } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { X } from "lucide-react";
import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management";
import { cn } from "@/lib/utils";
import { useActiveTab } from "@/contexts/ActiveTabContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
interface TabsWidgetProps {
component: TabsComponent;
className?: string;
style?: React.CSSProperties;
menuObjid?: number; // 부모 화면의 메뉴 OBJID
menuObjid?: number;
formData?: Record<string, any>;
onFormDataChange?: (data: Record<string, any>) => void;
isDesignMode?: boolean; // 디자인 모드 여부
onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백
selectedComponentId?: string; // 선택된 컴포넌트 ID
}
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
// ActiveTab context 사용
export function TabsWidget({
component,
className,
style,
menuObjid,
formData = {},
onFormDataChange,
isDesignMode = false,
onComponentSelect,
selectedComponentId,
}: TabsWidgetProps) {
const { setActiveTab, removeTabsComponent } = useActiveTab();
const {
tabs = [],
@ -28,7 +42,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
persistSelection = false,
} = component;
const storageKey = `tabs-${component.id}-selected`;
// 초기 선택 탭 결정
@ -44,9 +57,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
// 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱)
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
// 컴포넌트 탭 목록 변경 시 동기화
@ -59,14 +69,12 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
if (persistSelection && typeof window !== "undefined") {
localStorage.setItem(storageKey, selectedTab);
}
// ActiveTab Context에 현재 활성 탭 정보 등록
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
if (currentTabInfo) {
setActiveTab(component.id, {
tabId: selectedTab,
tabsComponentId: component.id,
screenId: currentTabInfo.screenId,
label: currentTabInfo.label,
});
}
@ -79,53 +87,16 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
};
}, [component.id, removeTabsComponent]);
// 초기 로드 시 선택된 탭의 화면 불러오기
useEffect(() => {
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
loadScreenLayout(currentTab.screenId);
}
}, [selectedTab, visibleTabs]);
// 화면 레이아웃 로드
const loadScreenLayout = async (screenId: number) => {
if (screenLayouts[screenId]) {
return; // 이미 로드됨
}
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
if (response.data.success && response.data.data) {
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
}
} catch (error) {
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
} finally {
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
}
};
// 탭 변경 핸들러
const handleTabChange = (tabId: string) => {
setSelectedTab(tabId);
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
setMountedTabs(prev => {
setMountedTabs((prev) => {
if (prev.has(tabId)) return prev;
const newSet = new Set(prev);
newSet.add(tabId);
return newSet;
});
// 해당 탭의 화면 로드
const tab = visibleTabs.find((t) => t.id === tabId);
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
loadScreenLayout(tab.screenId);
}
};
// 탭 닫기 핸들러
@ -135,7 +106,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId);
setVisibleTabs(updatedTabs);
// 닫은 탭이 선택된 탭이었다면 다음 탭 선택
if (selectedTab === tabId && updatedTabs.length > 0) {
setSelectedTab(updatedTabs[0].id);
}
@ -153,6 +123,68 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
return `${baseClass} ${variantClass}`;
};
// 인라인 컴포넌트 렌더링
const renderTabComponents = (tab: TabItem) => {
const components = tab.components || [];
if (components.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-muted-foreground text-sm">
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
</p>
</div>
);
}
return (
<div className="relative h-full w-full">
{components.map((comp: TabInlineComponent) => {
const isSelected = selectedComponentId === comp.id;
return (
<div
key={comp.id}
className={cn(
"absolute",
isDesignMode && "cursor-move",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
)}
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 200,
height: comp.size?.height || 100,
}}
onClick={(e) => {
if (isDesignMode && onComponentSelect) {
e.stopPropagation();
onComponentSelect(tab.id, comp.id);
}
}}
>
<DynamicComponentRenderer
component={{
id: comp.id,
componentType: comp.componentType,
label: comp.label,
position: comp.position,
size: comp.size,
componentConfig: comp.componentConfig || {},
style: comp.style,
}}
formData={formData}
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={isDesignMode}
/>
</div>
);
})}
</div>
);
};
if (visibleTabs.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
@ -162,7 +194,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
}
return (
<div className="flex h-full w-full flex-col pt-4" style={style}>
<div className={cn("flex h-full w-full flex-col pt-4", className)} style={style}>
<Tabs
value={selectedTab}
onValueChange={handleTabChange}
@ -175,6 +207,11 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
<div key={tab.id} className="relative">
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
{tab.label}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</TabsTrigger>
{allowCloseable && (
<Button
@ -191,86 +228,19 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
</TabsList>
</div>
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
<div className="relative flex-1 overflow-hidden">
{visibleTabs.map((tab) => {
// 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩)
const shouldRender = mountedTabs.has(tab.id);
const isActive = selectedTab === tab.id;
return (
<TabsContent
key={tab.id}
value={tab.id}
forceMount // 🆕 DOM에 항상 유지
className={cn(
"h-full",
!isActive && "hidden" // 🆕 비활성 탭은 CSS로 숨김
)}
<TabsContent
key={tab.id}
value={tab.id}
forceMount
className={cn("h-full", !isActive && "hidden")}
>
{/* 한 번 마운트된 탭만 내용 렌더링 */}
{shouldRender && (
<>
{tab.screenId ? (
loadingScreens[tab.screenId] ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-muted-foreground ml-2"> ...</span>
</div>
) : screenLayouts[tab.screenId] ? (
(() => {
const layoutData = screenLayouts[tab.screenId];
const { components = [], screenResolution } = layoutData;
const designWidth = screenResolution?.width || 1920;
const designHeight = screenResolution?.height || 1080;
return (
<div
className="relative h-full w-full overflow-auto bg-background"
style={{
minHeight: `${designHeight}px`,
}}
>
<div
className="relative"
style={{
width: `${designWidth}px`,
height: `${designHeight}px`,
margin: "0 auto",
}}
>
{components.map((comp: any) => (
<InteractiveScreenViewerDynamic
key={comp.id}
component={comp}
allComponents={components}
screenInfo={{
id: tab.screenId,
tableName: layoutData.tableName,
}}
menuObjid={menuObjid}
parentTabId={tab.id}
parentTabsComponentId={component.id}
/>
))}
</div>
</div>
);
})()
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
)
) : (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</>
)}
{shouldRender && renderTabComponents(tab)}
</TabsContent>
);
})}

View File

@ -152,6 +152,11 @@ export interface DynamicComponentRendererProps {
tableDisplayData?: any[]; // 🆕 화면 표시 데이터
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
// 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등)
onUpdateComponent?: (updatedComponent: any) => void;
// 🆕 탭 내부 컴포넌트 선택 콜백
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void;
selectedTabComponentId?: string;
flowSelectedStepId?: number | null;
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
// 테이블 새로고침 키
@ -754,6 +759,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
parentTabId: props.parentTabId,
parentTabsComponentId: props.parentTabsComponentId,
// 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등)
onUpdateComponent: props.onUpdateComponent,
// 🆕 탭 내부 컴포넌트 선택 콜백
onSelectTabComponent: props.onSelectTabComponent,
selectedTabComponentId: props.selectedTabComponentId,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -97,7 +97,6 @@ import "./v2-numbering-rule/NumberingRuleRenderer";
import "./v2-table-list/TableListRenderer";
import "./v2-text-display/TextDisplayRenderer";
import "./v2-pivot-grid/PivotGridRenderer";
import "./v2-repeat-screen-modal/RepeatScreenModalRenderer";
import "./v2-divider-line/DividerLineRenderer";
import "./v2-repeat-container/RepeatContainerRenderer";
import "./v2-section-card/SectionCardRenderer";

View File

@ -1,409 +0,0 @@
# RepeatScreenModal 컴포넌트 v3.1
## 개요
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
## v3.1 주요 변경사항 (2025-11-28)
### 1. 외부 테이블 데이터 소스
테이블 행에서 **외부 테이블의 데이터를 조회**하여 표시할 수 있습니다.
```
예시: 수주 관리에서 출하 계획 이력 조회
┌─────────────────────────────────────────────────────────────────┐
│ 카드: 품목 A │
├─────────────────────────────────────────────────────────────────┤
│ [행 1] 헤더: 품목코드, 품목명 │
├─────────────────────────────────────────────────────────────────┤
│ [행 2] 테이블: shipment_plan 테이블에서 조회 │
│ → sales_order_id로 조인하여 출하 계획 이력 표시 │
└─────────────────────────────────────────────────────────────────┘
```
### 2. 테이블 행 CRUD
테이블 행에서 **행 추가/수정/삭제** 기능을 지원합니다.
- **추가**: 새 행 추가 버튼으로 빈 행 생성
- **수정**: 편집 가능한 컬럼 직접 수정
- **삭제**: 행 삭제 (확인 팝업 옵션)
### 3. Footer 버튼 영역
모달 하단에 **커스터마이징 가능한 버튼 영역**을 제공합니다.
```
┌─────────────────────────────────────────────────────────────────┐
│ 카드 내용... │
├─────────────────────────────────────────────────────────────────┤
│ [초기화] [취소] [저장] │
└─────────────────────────────────────────────────────────────────┘
```
### 4. 집계 연산식 지원
집계 행에서 **컬럼 간 사칙연산**을 지원합니다.
```typescript
// 예: 미출하 수량 = 수주수량 - 출하수량
{
sourceType: "formula",
formula: "{order_qty} - {ship_qty}",
label: "미출하 수량"
}
```
---
## v3 주요 변경사항 (기존)
### 자유 레이아웃 시스템
기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다.
```
┌─────────────────────────────────────────────────────────────────┐
│ 카드 │
├─────────────────────────────────────────────────────────────────┤
│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │
├─────────────────────────────────────────────────────────────────┤
│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │
├─────────────────────────────────────────────────────────────────┤
│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │
├─────────────────────────────────────────────────────────────────┤
│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │
└─────────────────────────────────────────────────────────────────┘
```
### 행 타입
| 타입 | 설명 | 사용 시나리오 |
|------|------|---------------|
| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 |
| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 |
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
---
## 설정 방법
### 1. 기본 설정 탭
- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부
- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성
- **카드 간격**: 카드 사이의 간격 (8px ~ 32px)
- **테두리**: 카드 테두리 표시 여부
- **저장 모드**: 전체 저장 / 개별 저장
### 2. 데이터 소스 탭
- **소스 테이블**: 데이터를 조회할 테이블
- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds)
### 3. 그룹 탭
- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부
- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code)
- **집계 설정**:
- 원본 필드: 합계할 필드 (예: balance_qty)
- 집계 타입: sum, count, avg, min, max
- 결과 필드명: 집계 결과를 저장할 필드명
- 라벨: 표시될 라벨
### 4. 레이아웃 탭
#### 행 추가
4가지 타입의 행을 추가할 수 있습니다:
- **헤더**: 필드 정보 표시 (읽기전용)
- **집계**: 그룹 집계값 표시
- **테이블**: 그룹 내 행들을 테이블로 표시
- **필드**: 입력 필드 (편집가능)
#### 헤더/필드 행 설정
- **방향**: 가로 / 세로
- **배경색**: 없음, 파랑, 초록, 보라, 주황
- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수
- **소스 설정**: 직접 / 조인 / 수동
- **저장 설정**: 저장할 테이블과 컬럼
#### 집계 행 설정
- **레이아웃**: 가로 나열 / 그리드
- **그리드 컬럼 수**: 2, 3, 4개
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
- **스타일**: 배경색, 폰트 크기
#### 테이블 행 설정 (v3.1 확장)
- **테이블 제목**: 선택사항
- **헤더 표시**: 테이블 헤더 표시 여부
- **외부 테이블 데이터 소스**: (v3.1 신규)
- 소스 테이블: 조회할 외부 테이블
- 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키
- 정렬: 정렬 컬럼 및 방향
- **CRUD 설정**: (v3.1 신규)
- 추가: 새 행 추가 허용
- 수정: 행 수정 허용
- 삭제: 행 삭제 허용 (확인 팝업 옵션)
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
### 5. Footer 탭 (v3.1 신규)
- **Footer 사용**: Footer 영역 활성화
- **위치**: 컨텐츠 아래 / 하단 고정 (sticky)
- **정렬**: 왼쪽 / 가운데 / 오른쪽
- **버튼 설정**:
- 라벨: 버튼 텍스트
- 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀
- 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트
- 아이콘: 저장 / X / 초기화 / 없음
---
## 데이터 흐름
```
1. formData에서 selectedIds 가져오기
2. 소스 테이블에서 해당 ID들의 데이터 조회
3. 그룹핑 활성화 시 groupByField 기준으로 그룹화
4. 각 그룹에 대해 집계값 계산
5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1)
6. 카드 렌더링 (contentRows 기반)
7. 사용자 편집 (CRUD 포함)
8. Footer 버튼 또는 기본 저장 버튼으로 저장
9. 기본 데이터 + 외부 테이블 데이터 일괄 저장
```
---
## 사용 예시
### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD)
```typescript
{
showCardTitle: true,
cardTitle: "{part_code} - {part_name}",
dataSource: {
sourceTable: "sales_order_mng",
filterField: "selectedIds"
},
grouping: {
enabled: true,
groupByField: "part_code",
aggregations: [
{ sourceField: "balance_qty", type: "sum", resultField: "total_balance", label: "총수주잔량" },
{ sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" }
]
},
contentRows: [
{
id: "row-1",
type: "header",
columns: [
{ id: "c1", field: "part_code", label: "품목코드", type: "text", editable: false },
{ id: "c2", field: "part_name", label: "품목명", type: "text", editable: false }
],
layout: "horizontal"
},
{
id: "row-2",
type: "aggregation",
aggregationLayout: "horizontal",
aggregationFields: [
{ sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
{ sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" }
]
},
{
id: "row-3",
type: "table",
tableTitle: "출하 계획 이력",
showTableHeader: true,
// 외부 테이블에서 데이터 조회
tableDataSource: {
enabled: true,
sourceTable: "shipment_plan",
joinConditions: [
{ sourceKey: "sales_order_id", referenceKey: "id" }
],
orderBy: { column: "created_date", direction: "desc" }
},
// CRUD 설정
tableCrud: {
allowCreate: true,
allowUpdate: true,
allowDelete: true,
newRowDefaults: {
sales_order_id: "{id}",
status: "READY"
},
deleteConfirm: { enabled: true }
},
tableColumns: [
{ id: "tc1", field: "plan_date", label: "계획일", type: "date", editable: true },
{ id: "tc2", field: "plan_qty", label: "계획수량", type: "number", editable: true },
{ id: "tc3", field: "status", label: "상태", type: "text", editable: false },
{ id: "tc4", field: "memo", label: "비고", type: "text", editable: true }
]
}
],
// Footer 설정
footerConfig: {
enabled: true,
position: "sticky",
alignment: "right",
buttons: [
{ id: "btn-cancel", label: "취소", action: "cancel", variant: "outline" },
{ id: "btn-save", label: "저장", action: "save", variant: "default", icon: "save" }
]
}
}
```
---
## 타입 정의 (v3.1)
### TableDataSourceConfig
```typescript
interface TableDataSourceConfig {
enabled: boolean; // 외부 데이터 소스 사용 여부
sourceTable: string; // 조회할 테이블
joinConditions: JoinCondition[]; // 조인 조건
orderBy?: {
column: string; // 정렬 컬럼
direction: "asc" | "desc"; // 정렬 방향
};
limit?: number; // 최대 행 수
}
interface JoinCondition {
sourceKey: string; // 외부 테이블의 조인 키
referenceKey: string; // 카드 데이터의 참조 키
referenceType?: "card" | "row"; // 참조 소스
}
```
### TableCrudConfig
```typescript
interface TableCrudConfig {
allowCreate: boolean; // 행 추가 허용
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
newRowDefaults?: Record<string, string>; // 신규 행 기본값 ({field} 형식 지원)
deleteConfirm?: {
enabled: boolean; // 삭제 확인 팝업
message?: string; // 확인 메시지
};
targetTable?: string; // 저장 대상 테이블
}
```
### FooterConfig
```typescript
interface FooterConfig {
enabled: boolean; // Footer 사용 여부
buttons?: FooterButtonConfig[];
position?: "sticky" | "static";
alignment?: "left" | "center" | "right";
}
interface FooterButtonConfig {
id: string;
label: string;
action: "save" | "cancel" | "close" | "reset" | "custom";
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost";
icon?: string;
disabled?: boolean;
customAction?: {
type: string;
config?: Record<string, any>;
};
}
```
### AggregationDisplayConfig (v3.1 확장)
```typescript
interface AggregationDisplayConfig {
// 값 소스 타입
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
// aggregation: 기존 집계 결과 참조
aggregationResultField?: string;
// formula: 컬럼 간 연산
formula?: string; // 예: "{order_qty} - {ship_qty}"
// external: 외부 테이블 조회 (향후 구현)
externalSource?: ExternalValueSource;
// externalFormula: 외부 테이블 + 연산 (향후 구현)
externalSources?: ExternalValueSource[];
externalFormula?: string;
// 표시 설정
label: string;
icon?: string;
backgroundColor?: string;
textColor?: string;
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl";
format?: "number" | "currency" | "percent";
decimalPlaces?: number;
}
```
---
## 레거시 호환
v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다.
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
---
## 주의사항
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.
4. **외부 테이블 CRUD**: 외부 테이블 데이터 소스가 설정된 테이블에서만 CRUD가 동작합니다.
5. **연산식**: 사칙연산(+, -, *, /)과 괄호만 지원됩니다. 복잡한 함수는 지원하지 않습니다.
---
## 변경 이력
### v3.1 (2025-11-28)
- 외부 테이블 데이터 소스 기능 추가
- 테이블 행 CRUD (추가/수정/삭제) 기능 추가
- Footer 버튼 영역 기능 추가
- 집계 연산식 (formula) 지원 추가
- 다단계 조인 타입 정의 추가 (향후 구현 예정)
### v3.0
- 자유 레이아웃 시스템 도입
- contentRows 기반 행 타입 선택 방식
- 헤더/필드/집계/테이블 4가지 행 타입 지원
### v2.0
- simple 모드 / withTable 모드 구분
- cardLayout / tableLayout 분리

View File

@ -1,13 +0,0 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { V2RepeatScreenModalDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(V2RepeatScreenModalDefinition);
// console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
}
export {};

View File

@ -1,114 +0,0 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel";
import type {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
CardData,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
GroupedCardData,
CardRowData,
CardContentRowConfig,
AggregationDisplayConfig,
} from "./types";
/**
* RepeatScreenModal v3
* - ,
*
* :
* - 🆕 v3: 자유 - (Row) (///)
* - 그룹핑: 특정
* - 집계: 그룹 //
* - 테이블: 그룹
* - 레이아웃: ,
* - 설정: 직접 / /
* - 설정: 어느
* - 저장: 하나의
*
* :
* - ( + )
* - ( + )
* - ( + )
* - ( + )
*/
export const V2RepeatScreenModalDefinition = createComponentDefinition({
id: "v2-repeat-screen-modal",
name: "반복 화면 모달",
nameEng: "Repeat Screen Modal",
description:
"선택한 행을 그룹핑하여 카드로 표시하고, 각 카드는 헤더/집계/테이블을 자유롭게 구성 가능한 폼 (출하계획, 구매발주 등)",
category: ComponentCategory.DATA,
webType: "form",
component: RepeatScreenModalComponent,
defaultConfig: {
// 기본 설정
showCardTitle: true,
cardTitle: "카드 {index}",
cardSpacing: "24px",
showCardBorder: true,
saveMode: "all",
// 데이터 소스
dataSource: {
sourceTable: "",
filterField: "selectedIds",
},
// 그룹핑 설정
grouping: {
enabled: false,
groupByField: "",
aggregations: [],
},
// 🆕 v3: 자유 레이아웃 (행 추가 후 타입 선택)
contentRows: [],
// (레거시 호환)
cardMode: "simple",
cardLayout: [],
tableLayout: {
headerRows: [],
tableColumns: [],
},
} as Partial<RepeatScreenModalProps>,
defaultSize: { width: 1000, height: 800 },
configPanel: RepeatScreenModalConfigPanel,
icon: "LayoutGrid",
tags: ["모달", "폼", "반복", "카드", "그룹핑", "집계", "테이블", "편집", "데이터", "출하계획", "일괄등록", "자유레이아웃"],
version: "3.0.0",
author: "개발팀",
});
// 타입 재 export
export type {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
CardData,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
GroupedCardData,
CardRowData,
CardContentRowConfig,
AggregationDisplayConfig,
};
// 컴포넌트 재 export
export { RepeatScreenModalComponent, RepeatScreenModalConfigPanel };

View File

@ -1,525 +0,0 @@
import { ComponentRendererProps } from "@/types/component";
/**
* RepeatScreenModal Props
* ,
*
* 🆕 v3: (Row) - (//)
*/
export interface RepeatScreenModalProps {
// === 기본 설정 ===
showCardTitle?: boolean; // 카드 제목 표시 여부
cardTitle?: string; // 카드 제목 템플릿 (예: "{order_no} - {item_code}")
cardSpacing?: string; // 카드 간 간격 (기본: 24px)
showCardBorder?: boolean; // 카드 테두리 표시 여부
saveMode?: "all" | "individual"; // 저장 모드
// === 데이터 소스 ===
dataSource?: DataSourceConfig; // 데이터 소스 설정
// === 그룹핑 설정 ===
grouping?: GroupingConfig; // 그룹핑 설정
// === 🆕 v3: 자유 레이아웃 ===
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
// === 🆕 v3.1: Footer 버튼 설정 ===
footerConfig?: FooterConfig; // Footer 영역 설정
// === (레거시 호환) ===
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
tableLayout?: TableLayoutConfig; // @deprecated - contentRows 사용 권장
// === 값 ===
value?: any[];
onChange?: (newData: any[]) => void;
}
/**
* 🆕 v3.1: Footer
*/
export interface FooterConfig {
enabled: boolean; // Footer 사용 여부
buttons?: FooterButtonConfig[]; // Footer 버튼들
position?: "sticky" | "static"; // sticky: 하단 고정, static: 컨텐츠 아래
alignment?: "left" | "center" | "right"; // 버튼 정렬
}
/**
* 🆕 v3.1: Footer
*/
export interface FooterButtonConfig {
id: string; // 버튼 고유 ID
label: string; // 버튼 라벨
action: "save" | "cancel" | "close" | "reset" | "custom"; // 액션 타입
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일
icon?: string; // 아이콘 (lucide 아이콘명)
disabled?: boolean; // 비활성화 여부
// custom 액션일 때
customAction?: {
type: string; // 커스텀 액션 타입
config?: Record<string, any>; // 커스텀 설정
};
}
/**
*
*/
export interface DataSourceConfig {
sourceTable: string; // 조회할 테이블 (예: "sales_order_mng")
filterField?: string; // formData에서 필터링할 필드 (예: "selectedIds")
selectColumns?: string[]; // 선택할 컬럼 목록
}
/**
*
*
*/
export interface GroupingConfig {
enabled: boolean; // 그룹핑 활성화 여부
groupByField: string; // 그룹 기준 필드 (예: "part_code")
// 집계 설정 (그룹별 합계, 개수 등)
aggregations?: AggregationConfig[];
}
/**
* 🆕 v3: 카드
* (//)
*/
export interface CardContentRowConfig {
id: string; // 행 고유 ID
type: "header" | "aggregation" | "table" | "fields"; // 행 타입
// === header/fields 타입일 때 ===
columns?: CardColumnConfig[]; // 컬럼 설정
layout?: "horizontal" | "vertical"; // 레이아웃 방향
gap?: string; // 컬럼 간 간격
backgroundColor?: string; // 배경색
padding?: string; // 패딩
// === aggregation 타입일 때 ===
aggregationFields?: AggregationDisplayConfig[]; // 표시할 집계 필드들
aggregationLayout?: "horizontal" | "grid"; // 집계 레이아웃 (가로 나열 / 그리드)
aggregationColumns?: number; // grid일 때 컬럼 수 (기본: 4)
// === table 타입일 때 ===
tableColumns?: TableColumnConfig[]; // 테이블 컬럼 설정
tableTitle?: string; // 테이블 제목
showTableHeader?: boolean; // 테이블 헤더 표시 여부
tableMaxHeight?: string; // 테이블 최대 높이
// 🆕 v3.1: 테이블 외부 데이터 소스
tableDataSource?: TableDataSourceConfig; // 외부 테이블에서 데이터 조회
// 🆕 v3.1: 테이블 CRUD 설정
tableCrud?: TableCrudConfig; // 행 추가/수정/삭제 설정
}
/**
* 🆕 v3.1: 테이블
*
*/
export interface TableDataSourceConfig {
enabled: boolean; // 외부 데이터 소스 사용 여부
sourceTable: string; // 조회할 테이블 (예: "shipment_plan")
// 조인 설정
joinConditions: JoinCondition[]; // 조인 조건 (복합 키 지원)
// 🆕 v3.3: 추가 조인 테이블 설정 (소스 테이블에 없는 컬럼 조회)
additionalJoins?: AdditionalJoinConfig[];
// 🆕 v3.4: 필터 조건 설정 (그룹 내 특정 조건으로 필터링)
filterConfig?: TableFilterConfig;
// 정렬 설정
orderBy?: {
column: string; // 정렬 컬럼
direction: "asc" | "desc"; // 정렬 방향
};
// 제한
limit?: number; // 최대 행 수
}
/**
* 🆕 v3.4: 테이블
*
*/
export interface TableFilterConfig {
enabled: boolean; // 필터 사용 여부
filterField: string; // 필터링할 필드 (예: "order_no")
filterType: "equals" | "notEquals"; // equals: 같은 값만, notEquals: 다른 값만
referenceField: string; // 비교 기준 필드 (formData 또는 카드 대표 데이터에서)
referenceSource: "formData" | "representativeData"; // 비교 값 소스
}
/**
* 🆕 v3.3: 추가
*
*/
export interface AdditionalJoinConfig {
id: string; // 조인 설정 고유 ID
joinTable: string; // 조인할 테이블 (예: "sales_order_mng")
joinType: "left" | "inner"; // 조인 타입
sourceKey: string; // 소스 테이블의 조인 키 (예: "sales_order_id")
targetKey: string; // 조인 테이블의 키 (예: "id")
alias?: string; // 테이블 별칭 (예: "so")
selectColumns?: string[]; // 가져올 컬럼 목록 (비어있으면 전체)
}
/**
* 🆕 v3.1: 조인
*/
export interface JoinCondition {
sourceKey: string; // 외부 테이블의 조인 키 (예: "sales_order_id")
referenceKey: string; // 현재 카드 데이터의 참조 키 (예: "id")
referenceType?: "card" | "row"; // card: 카드 대표 데이터, row: 각 행 데이터 (기본: card)
}
/**
* 🆕 v3.1: 테이블 CRUD
*/
export interface TableCrudConfig {
allowCreate: boolean; // 행 추가 허용
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
// 신규 행 기본값
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
// 삭제 확인
deleteConfirm?: {
enabled: boolean; // 삭제 확인 팝업 표시 여부
message?: string; // 확인 메시지
};
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
syncSaves?: SyncSaveConfig[];
// 🆕 v3.13: 행 추가 시 자동 채번 설정
rowNumbering?: RowNumberingConfig;
}
/**
* 🆕 v3.13: 테이블
* "추가"
*
* :
* - (shipment_plan_no)
* - (invoice_no)
* - (work_order_no)
*
* 참고: 채번 "수정 가능"
*/
export interface RowNumberingConfig {
enabled: boolean; // 채번 사용 여부
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
}
/**
* 🆕 v3.12: 연동
*
*/
export interface SyncSaveConfig {
id: string; // 고유 ID
enabled: boolean; // 활성화 여부
// 소스 설정 (이 테이블에서)
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
// 대상 설정 (저장할 테이블)
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
// 조인 키 (어떤 레코드를 업데이트할지)
joinKey: {
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
targetField: string; // 대상 테이블의 키 (예: "id")
};
}
/**
* 🆕 v3: 집계
*/
export interface AggregationDisplayConfig {
// 값 소스 타입
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
// === sourceType: "aggregation" (기존 그룹핑 집계 결과 참조) ===
aggregationResultField?: string; // 그룹핑 설정의 resultField 참조
// === sourceType: "formula" (컬럼 간 연산) ===
formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}")
// === sourceType: "external" (외부 테이블 조회) ===
externalSource?: ExternalValueSource;
// === sourceType: "externalFormula" (외부 테이블 + 연산) ===
externalSources?: ExternalValueSource[]; // 여러 외부 소스
externalFormula?: string; // 외부 값들을 조합한 연산식 (예: "{inv_qty} + {prod_qty}")
// 표시 설정
label: string; // 표시 라벨
icon?: string; // 아이콘 (lucide 아이콘명)
backgroundColor?: string; // 배경색
textColor?: string; // 텍스트 색상
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
format?: "number" | "currency" | "percent"; // 숫자 포맷
decimalPlaces?: number; // 소수점 자릿수
}
/**
* 🆕 v3.1: 외부
*/
export interface ExternalValueSource {
alias: string; // 연산식에서 사용할 별칭 (예: "inv_qty")
sourceTable: string; // 조회할 테이블
sourceColumn: string; // 조회할 컬럼
aggregationType?: "sum" | "count" | "avg" | "min" | "max" | "first"; // 집계 타입 (기본: first)
// 조인 설정 (다단계 조인 지원)
joins: ChainedJoinConfig[];
}
/**
* 🆕 v3.1: 다단계
*/
export interface ChainedJoinConfig {
step: number; // 조인 순서 (1, 2, 3...)
sourceTable: string; // 조인할 테이블
joinConditions: {
sourceKey: string; // 조인 테이블의 키
referenceKey: string; // 참조 키 (이전 단계 결과 또는 카드 데이터)
referenceFrom?: "card" | "previousStep"; // 참조 소스 (기본: card, step > 1이면 previousStep)
}[];
selectColumns?: string[]; // 이 단계에서 선택할 컬럼
}
/**
*
* 🆕 v3.2: 다중 (formula)
* 🆕 v3.9: 연관
*/
export interface AggregationConfig {
// === 집계 소스 타입 ===
sourceType: "column" | "formula"; // column: 테이블 컬럼 집계, formula: 연산식 (가상 집계)
// === sourceType: "column" (테이블 컬럼 집계) ===
sourceTable?: string; // 집계할 테이블 (기본: dataSource.sourceTable, 외부 테이블도 가능)
sourceField?: string; // 원본 필드 (예: "balance_qty")
type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
// === sourceType: "formula" (가상 집계 - 연산식) ===
// 연산식 문법:
// - {resultField}: 다른 집계 결과 참조 (예: {total_balance})
// - {테이블.컬럼}: 테이블의 컬럼 직접 참조 (예: {sales_order_mng.order_qty})
// - SUM({컬럼}): 기본 테이블 행들의 합계
// - SUM_EXT({컬럼}): 외부 테이블 행들의 합계 (externalTableData)
// - 산술 연산: +, -, *, /, ()
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
// === 공통 ===
resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량")
// === 🆕 v3.10: 숨김 설정 ===
hidden?: boolean; // 레이아웃에서 숨김 (연산에만 사용, 기본: false)
// === 🆕 v3.9: 저장 설정 ===
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
}
/**
* 🆕 v3.9: 집계
*
*/
export interface AggregationSaveConfig {
enabled: boolean; // 저장 활성화 여부
autoSave: boolean; // 자동 저장 (레이아웃에 없어도 저장)
// 저장 대상
targetTable: string; // 저장할 테이블 (예: "sales_order_mng")
targetColumn: string; // 저장할 컬럼 (예: "plan_qty_total")
// 조인 키 (어떤 레코드를 업데이트할지)
joinKey: {
sourceField: string; // 현재 카드의 조인 키 (예: "id" 또는 "sales_order_id")
targetField: string; // 대상 테이블의 키 (예: "id")
};
}
/**
* @deprecated v3에서는 contentRows
*
*/
export interface TableLayoutConfig {
headerRows: CardRowConfig[];
tableColumns: TableColumnConfig[];
tableTitle?: string;
showTableHeader?: boolean;
tableMaxHeight?: string;
}
/**
*
*/
export interface TableColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명 (소스 테이블 컬럼 또는 조인 테이블 컬럼)
label: string; // 헤더 라벨
type: "text" | "number" | "date" | "select" | "badge"; // 타입
width?: string; // 너비 (예: "100px", "20%")
align?: "left" | "center" | "right"; // 정렬
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
hidden?: boolean; // 숨김 여부
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];
// Badge 타입 설정
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
badgeColorMap?: Record<string, string>; // 값별 색상 매핑
// 데이터 소스 설정
sourceConfig?: ColumnSourceConfig;
// 데이터 타겟 설정
targetConfig?: ColumnTargetConfig;
}
/**
*
* (Row) ,
*/
export interface CardRowConfig {
id: string; // 행 고유 ID
columns: CardColumnConfig[]; // 이 행에 배치할 컬럼들
gap?: string; // 컬럼 간 간격 (기본: 16px)
layout?: "horizontal" | "vertical"; // 레이아웃 방향 (기본: horizontal)
// 🆕 행 스타일 설정
backgroundColor?: string; // 배경색 (예: "blue", "green")
padding?: string; // 패딩
rounded?: boolean; // 둥근 모서리
}
/**
*
*/
export interface CardColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명 (데이터 바인딩)
label: string; // 라벨
type: "text" | "number" | "date" | "select" | "textarea" | "component" | "aggregation"; // 🆕 aggregation 추가
width?: string; // 너비 (예: "50%", "200px", "1fr")
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
placeholder?: string; // 플레이스홀더
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];
// 데이터 소스 설정 (어디서 조회?)
sourceConfig?: ColumnSourceConfig;
// 데이터 타겟 설정 (어디에 저장?)
targetConfig?: ColumnTargetConfig;
// Component 타입일 때
componentType?: string; // 삽입할 컴포넌트 타입 (예: "simple-repeater-table")
componentConfig?: any; // 컴포넌트 설정
// 🆕 Aggregation 타입일 때 (집계값 표시)
aggregationField?: string; // 표시할 집계 필드명 (GroupingConfig.aggregations의 resultField)
// 🆕 스타일 설정
textColor?: string; // 텍스트 색상
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
fontWeight?: "normal" | "medium" | "semibold" | "bold"; // 폰트 굵기
}
/**
* (SimpleRepeaterTable과 )
*/
export interface ColumnSourceConfig {
type: "direct" | "join" | "manual"; // 조회 타입
sourceTable?: string; // type: "direct" - 조회할 테이블
sourceColumn?: string; // type: "direct" - 조회할 컬럼
joinTable?: string; // type: "join" - 조인할 테이블
joinColumn?: string; // type: "join" - 조인 테이블에서 가져올 컬럼
joinKey?: string; // type: "join" - 현재 데이터의 조인 키 컬럼
joinRefKey?: string; // type: "join" - 조인 테이블의 참조 키 컬럼
}
/**
* (SimpleRepeaterTable과 )
*/
export interface ColumnTargetConfig {
targetTable: string; // 저장할 테이블
targetColumn: string; // 저장할 컬럼
saveEnabled?: boolean; // 저장 활성화 여부 (기본 true)
}
/**
* ( )
*/
export interface CardData {
_cardId: string; // 카드 고유 ID
_originalData: Record<string, any>; // 원본 데이터 (조회된 데이터)
_isDirty: boolean; // 수정 여부
[key: string]: any; // 실제 필드 데이터
}
/**
* 🆕
*/
export interface GroupedCardData {
_cardId: string; // 카드 고유 ID
_groupKey: string; // 그룹 키 값 (예: "PROD-001")
_groupField: string; // 그룹 기준 필드명 (예: "part_code")
_aggregations: Record<string, number>; // 집계 결과 (예: { total_balance_qty: 100 })
_rows: CardRowData[]; // 그룹 내 각 행 데이터
_representativeData: Record<string, any>; // 그룹 대표 데이터 (첫 번째 행 기준)
}
/**
* 🆕
*/
export interface CardRowData {
_rowId: string; // 행 고유 ID
_originalData: Record<string, any>; // 원본 데이터
_isDirty: boolean; // 수정 여부
[key: string]: any; // 실제 필드 데이터
}
/**
* (API )
*/
export interface TableInfo {
tableName: string;
displayName?: string;
}

View File

@ -5373,7 +5373,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 🆕 DevExpress 스타일 기능 툴바 */}
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
{/* 편집 모드 토글 */}
{(tableConfig.toolbar?.showEditMode ?? true) && (
{(tableConfig.toolbar?.showEditMode ?? false) && (
<div className="border-border flex items-center gap-1 border-r pr-2">
<Button
variant={editMode === "batch" ? "default" : "ghost"}
@ -5389,9 +5389,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 내보내기 버튼들 */}
{((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && (
{((tableConfig.toolbar?.showExcel ?? false) || (tableConfig.toolbar?.showPdf ?? false)) && (
<div className="border-border flex items-center gap-1 border-r pr-2">
{(tableConfig.toolbar?.showExcel ?? true) && (
{(tableConfig.toolbar?.showExcel ?? false) && (
<Button
variant="ghost"
size="sm"
@ -5403,7 +5403,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
Excel
</Button>
)}
{(tableConfig.toolbar?.showPdf ?? true) && (
{(tableConfig.toolbar?.showPdf ?? false) && (
<Button
variant="ghost"
size="sm"
@ -5419,7 +5419,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 복사 버튼 */}
{(tableConfig.toolbar?.showCopy ?? true) && (
{(tableConfig.toolbar?.showCopy ?? false) && (
<div className="border-border flex items-center gap-1 border-r pr-2">
<Button
variant="ghost"
@ -5454,7 +5454,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 🆕 통합 검색 패널 */}
{(tableConfig.toolbar?.showSearch ?? true) && (
{(tableConfig.toolbar?.showSearch ?? false) && (
<div className="border-border flex items-center gap-1 border-r pr-2">
{isSearchPanelOpen ? (
<div className="flex items-center gap-1">
@ -5529,7 +5529,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
{(tableConfig.toolbar?.showFilter ?? true) && (
{(tableConfig.toolbar?.showFilter ?? false) && (
<div className="border-border flex items-center gap-1 border-r pr-2">
<Button
variant={activeFilterCount > 0 ? "default" : "ghost"}
@ -5558,8 +5558,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
{/* 새로고침 */}
{(tableConfig.toolbar?.showRefresh ?? true) && (
{/* 새로고침 (상단) */}
{(tableConfig.toolbar?.showRefresh ?? false) && (
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"

View File

@ -771,7 +771,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showEditMode"
checked={config.toolbar?.showEditMode ?? true}
checked={config.toolbar?.showEditMode ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
/>
<Label htmlFor="showEditMode" className="text-xs"> </Label>
@ -779,7 +779,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showExcel"
checked={config.toolbar?.showExcel ?? true}
checked={config.toolbar?.showExcel ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
/>
<Label htmlFor="showExcel" className="text-xs">Excel</Label>
@ -787,7 +787,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showPdf"
checked={config.toolbar?.showPdf ?? true}
checked={config.toolbar?.showPdf ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
/>
<Label htmlFor="showPdf" className="text-xs">PDF</Label>
@ -795,7 +795,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showCopy"
checked={config.toolbar?.showCopy ?? true}
checked={config.toolbar?.showCopy ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
/>
<Label htmlFor="showCopy" className="text-xs"></Label>
@ -803,7 +803,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showSearch"
checked={config.toolbar?.showSearch ?? true}
checked={config.toolbar?.showSearch ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
/>
<Label htmlFor="showSearch" className="text-xs"></Label>
@ -811,7 +811,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showFilter"
checked={config.toolbar?.showFilter ?? true}
checked={config.toolbar?.showFilter ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
/>
<Label htmlFor="showFilter" className="text-xs"></Label>
@ -819,7 +819,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
id="showRefresh"
checked={config.toolbar?.showRefresh ?? true}
checked={config.toolbar?.showRefresh ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
/>
<Label htmlFor="showRefresh" className="text-xs"> ()</Label>

View File

@ -23,16 +23,16 @@ export const V2TableListDefinition = createComponentDefinition({
defaultConfig: {
// 표시 모드 설정
displayMode: "table" as const,
// 카드 모드 기본 설정
cardConfig: {
idColumn: "id",
titleColumn: "name",
titleColumn: "name",
cardsPerRow: 3,
cardSpacing: 16,
showActions: true,
},
// 테이블 기본 설정
showHeader: true,
showFooter: true,
@ -93,6 +93,18 @@ export const V2TableListDefinition = createComponentDefinition({
borderStyle: "light",
},
// 툴바 설정 (기본값: 새로고침 하단만 활성화)
toolbar: {
showEditMode: false,
showExcel: false,
showPdf: false,
showCopy: false,
showSearch: false,
showFilter: false,
showRefresh: false,
showPaginationRefresh: true, // 새로고침 (하단)만 기본 활성화
},
// 데이터 로딩
autoLoad: true,
},

View File

@ -1,21 +1,357 @@
"use client";
import React from "react";
import React, { useState, useRef, useCallback } from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { ComponentCategory } from "@/types/component";
import { Folder } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management";
import { Folder, Plus, Move, Settings, Trash2 } from "lucide-react";
import type { TabItem, TabInlineComponent } from "@/types/screen-management";
import { cn } from "@/lib/utils";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
// 디자인 모드용 탭 에디터 컴포넌트
const TabsDesignEditor: React.FC<{
component: any;
tabs: TabItem[];
onUpdateComponent?: (updatedComponent: any) => void;
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
selectedTabComponentId?: string;
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => {
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const activeTab = tabs.find((t) => t.id === activeTabId);
const getTabStyle = (tab: TabItem) => {
const isActive = tab.id === activeTabId;
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
);
};
// 컴포넌트 삭제
const handleDeleteComponent = useCallback(
(compId: string) => {
if (!onUpdateComponent) return;
const updatedTabs = tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
...tab,
components: (tab.components || []).filter((c) => c.id !== compId),
};
}
return tab;
});
onUpdateComponent({
...component,
componentConfig: {
...component.componentConfig,
tabs: updatedTabs,
},
});
},
[activeTabId, component, onUpdateComponent, tabs]
);
// 컴포넌트 드래그 시작
const handleDragStart = useCallback(
(e: React.MouseEvent, comp: TabInlineComponent) => {
e.stopPropagation();
e.preventDefault();
if (!containerRef.current) return;
const targetElement = (e.currentTarget as HTMLElement);
const targetRect = targetElement.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
// 스크롤 위치 고려
const scrollLeft = containerRef.current.scrollLeft;
const scrollTop = containerRef.current.scrollTop;
// 마우스 클릭 위치에서 컴포넌트의 좌상단까지의 오프셋
const offsetX = e.clientX - targetRect.left;
const offsetY = e.clientY - targetRect.top;
// 초기 컨테이너 위치 저장
const initialContainerX = containerRect.left;
const initialContainerY = containerRect.top;
setDraggingCompId(comp.id);
setDragOffset({ x: offsetX, y: offsetY });
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!containerRef.current) return;
// 현재 컨테이너의 위치 가져오기 (스크롤/리사이즈 고려)
const currentContainerRect = containerRef.current.getBoundingClientRect();
const currentScrollLeft = containerRef.current.scrollLeft;
const currentScrollTop = containerRef.current.scrollTop;
// 컨테이너 내에서의 위치 계산 (스크롤 포함)
const newX = moveEvent.clientX - currentContainerRect.left - offsetX + currentScrollLeft;
const newY = moveEvent.clientY - currentContainerRect.top - offsetY + currentScrollTop;
// 실시간 위치 업데이트 (시각적 피드백)
const draggedElement = document.querySelector(
`[data-tab-comp-id="${comp.id}"]`
) as HTMLElement;
if (draggedElement) {
draggedElement.style.left = `${Math.max(0, newX)}px`;
draggedElement.style.top = `${Math.max(0, newY)}px`;
}
};
const handleMouseUp = (upEvent: MouseEvent) => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
setDraggingCompId(null);
if (!containerRef.current) {
return;
}
const currentContainerRect = containerRef.current.getBoundingClientRect();
const currentScrollLeft = containerRef.current.scrollLeft;
const currentScrollTop = containerRef.current.scrollTop;
const newX = upEvent.clientX - currentContainerRect.left - offsetX + currentScrollLeft;
const newY = upEvent.clientY - currentContainerRect.top - offsetY + currentScrollTop;
// 탭 컴포넌트 위치 업데이트
if (onUpdateComponent) {
const updatedTabs = tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
...tab,
components: (tab.components || []).map((c) =>
c.id === comp.id
? {
...c,
position: {
x: Math.max(0, Math.round(newX)),
y: Math.max(0, Math.round(newY)),
},
}
: c
),
};
}
return tab;
});
onUpdateComponent({
...component,
componentConfig: {
...component.componentConfig,
tabs: updatedTabs,
},
});
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
setDraggingCompId(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[activeTabId, component, onUpdateComponent, tabs]
);
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div
key={tab.id}
className={getTabStyle(tab)}
onClick={(e) => {
e.stopPropagation();
setActiveTabId(tab.id);
onSelectTabComponent?.(null);
}}
>
{tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div>
))
) : (
<div className="px-4 py-2 text-sm text-muted-foreground">
</div>
)}
</div>
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
<div
ref={containerRef}
className="relative flex-1 overflow-hidden"
data-tabs-container="true"
data-component-id={component.id}
data-active-tab-id={activeTabId}
onClick={() => onSelectTabComponent?.(activeTabId, "", {} as TabInlineComponent)}
>
{activeTab ? (
<div className="absolute inset-0 overflow-auto p-2">
{activeTab.components && activeTab.components.length > 0 ? (
<div className="relative h-full w-full">
{activeTab.components.map((comp: TabInlineComponent) => {
const isSelected = selectedTabComponentId === comp.id;
const isDragging = draggingCompId === comp.id;
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: comp.position || { x: 0, y: 0 },
size: comp.size || { width: 200, height: 100 },
componentConfig: comp.componentConfig || {},
style: comp.style || {},
};
return (
<div
key={comp.id}
data-tab-comp-id={comp.id}
className={cn(
"absolute rounded border bg-white shadow-sm transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-primary/50",
isDragging && "opacity-80 shadow-lg"
)}
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 200,
height: comp.size?.height || 100,
zIndex: isDragging ? 100 : isSelected ? 10 : 1,
}}
onClick={(e) => {
e.stopPropagation();
onSelectTabComponent?.(activeTabId, comp.id, comp);
}}
>
{/* 드래그 핸들 - 상단 */}
<div
className="absolute left-0 right-0 top-0 z-10 flex h-5 cursor-move items-center justify-between bg-gray-100/80 px-1"
onMouseDown={(e) => handleDragStart(e, comp)}
>
<div className="flex items-center gap-1">
<Move className="h-3 w-3 text-gray-400" />
<span className="text-[10px] text-gray-500 truncate max-w-[120px]">
{comp.label || comp.componentType}
</span>
</div>
<div className="flex items-center gap-1">
<button
className="rounded p-0.5 hover:bg-gray-200"
onClick={(e) => {
e.stopPropagation();
onSelectTabComponent?.(activeTabId, comp.id, comp);
}}
title="설정"
>
<Settings className="h-3 w-3 text-gray-500" />
</button>
<button
className="rounded p-0.5 hover:bg-red-100"
onClick={(e) => {
e.stopPropagation();
handleDeleteComponent(comp.id);
}}
title="삭제"
>
<Trash2 className="h-3 w-3 text-red-500" />
</button>
</div>
</div>
{/* 실제 컴포넌트 렌더링 */}
<div className="h-full w-full pt-5 overflow-hidden pointer-events-none">
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}
formData={{}}
/>
</div>
</div>
);
})}
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
<Plus className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm font-medium text-gray-500">
</p>
<p className="mt-1 text-xs text-gray-400">
</p>
</div>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</div>
);
};
// TabsWidget 래퍼 컴포넌트
const TabsWidgetWrapper: React.FC<any> = (props) => {
const { component, ...restProps } = props;
const {
component,
isDesignMode,
onUpdateComponent,
onSelectTabComponent,
selectedTabComponentId,
...restProps
} = props;
// componentConfig에서 탭 정보 추출
const tabsConfig = component.componentConfig || {};
const tabs: TabItem[] = tabsConfig.tabs || [];
// 🎯 디자인 모드에서는 드롭 가능한 에디터 UI 렌더링
if (isDesignMode) {
return (
<TabsDesignEditor
component={component}
tabs={tabs}
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
/>
);
}
// 실행 모드에서는 TabsWidget 렌더링
const tabsComponent = {
...component,
type: "tabs" as const,
tabs: tabsConfig.tabs || [],
tabs: tabs,
defaultTab: tabsConfig.defaultTab,
orientation: tabsConfig.orientation || "horizontal",
variant: tabsConfig.variant || "default",
@ -23,10 +359,9 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
persistSelection: tabsConfig.persistSelection || false,
};
const TabsWidget =
require("@/components/screen/widgets/TabsWidget").TabsWidget;
// TabsWidget 동적 로드
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
return (
<div className="h-full w-full">
<TabsWidget component={tabsComponent} {...restProps} />
@ -36,26 +371,49 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
/**
*
*
*
*
*
*/
ComponentRegistry.registerComponent({
id: "v2-tabs-widget",
name: "탭 컴포넌트",
description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.",
description:
"탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트입니다.",
category: ComponentCategory.LAYOUT,
webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값
component: TabsWidgetWrapper, // ✅ 실제 TabsWidget 렌더러
defaultConfig: {},
tags: ["tabs", "navigation", "layout", "screen"],
webType: "text" as any,
component: TabsWidgetWrapper,
defaultConfig: {
tabs: [
{
id: "tab-1",
label: "탭 1",
order: 0,
disabled: false,
components: [],
},
{
id: "tab-2",
label: "탭 2",
order: 1,
disabled: false,
components: [],
},
],
defaultTab: "tab-1",
orientation: "horizontal",
variant: "default",
allowCloseable: false,
persistSelection: false,
},
tags: ["tabs", "navigation", "layout", "container"],
icon: Folder,
version: "1.0.0",
version: "2.0.0",
defaultSize: {
width: 800,
height: 600,
},
defaultProps: {
type: "tabs" as const,
tabs: [
@ -64,12 +422,14 @@ ComponentRegistry.registerComponent({
label: "탭 1",
order: 0,
disabled: false,
components: [],
},
{
id: "tab-2",
label: "탭 2",
order: 1,
disabled: false,
components: [],
},
] as TabItem[],
defaultTab: "tab-1",
@ -78,82 +438,167 @@ ComponentRegistry.registerComponent({
allowCloseable: false,
persistSelection: false,
},
// 에디터 모드에서의 렌더링
renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, children }) => {
const tabsComponent = component as TabsComponent;
const tabs = tabsComponent.tabs || [];
// 에디터 모드에서의 렌더링 - 탭 선택 및 컴포넌트 드롭 지원
renderEditor: ({
component,
isSelected,
onClick,
onDragStart,
onDragEnd,
}) => {
const tabsConfig = (component as any).componentConfig || {};
const tabs: TabItem[] = tabsConfig.tabs || [];
// 에디터 모드에서 선택된 탭 상태 관리
const [activeTabId, setActiveTabId] = useState<string>(
tabs[0]?.id || ""
);
const activeTab = tabs.find((t) => t.id === activeTabId);
// 탭 스타일 클래스
const getTabStyle = (tab: TabItem) => {
const isActive = tab.id === activeTabId;
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
);
};
return (
<div
className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50"
className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background"
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="text-center">
<div className="flex items-center justify-center">
<Folder className="h-8 w-8 text-gray-400" />
</div>
<p className="text-muted-foreground mt-2 text-sm font-medium"> </p>
<p className="text-xs text-gray-400">
{tabs.length > 0
? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)`
: "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}
</p>
{tabs.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">
{tabs.map((tab: TabItem, index: number) => (
<span
key={tab.id}
className="rounded-md border bg-white px-2 py-1 text-xs"
>
{tab.label || `${index + 1}`}
</span>
))}
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div
key={tab.id}
className={getTabStyle(tab)}
onClick={(e) => {
e.stopPropagation();
setActiveTabId(tab.id);
}}
>
{tab.label || "탭"}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
)}
</div>
))
) : (
<div className="px-4 py-2 text-sm text-muted-foreground">
</div>
)}
</div>
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
<div
className="relative flex-1 overflow-hidden"
data-tabs-container="true"
data-component-id={component.id}
data-active-tab-id={activeTabId}
>
{activeTab ? (
<div className="absolute inset-0 overflow-auto p-2">
{activeTab.components && activeTab.components.length > 0 ? (
<div className="relative h-full w-full">
{activeTab.components.map((comp: TabInlineComponent) => (
<div
key={comp.id}
className="absolute rounded border border-dashed border-gray-300 bg-white/80 p-2 shadow-sm"
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 200,
height: comp.size?.height || 100,
}}
>
<div className="flex h-full flex-col items-center justify-center">
<span className="text-xs font-medium text-gray-600">
{comp.label || comp.componentType}
</span>
<span className="text-[10px] text-gray-400">
{comp.componentType}
</span>
</div>
</div>
))}
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
<Plus className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm font-medium text-gray-500">
</p>
<p className="mt-1 text-xs text-gray-400">
</p>
</div>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
{/* 선택 표시 */}
{isSelected && (
<div className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-primary ring-offset-2" />
)}
</div>
);
},
// 인터랙티브 모드에서의 렌더링 (실제 동작)
// 인터랙티브 모드에서의 렌더링
renderInteractive: ({ component }) => {
// InteractiveScreenViewer에서 TabsWidget을 사용하므로 여기서는 null 반환
return null;
},
// 설정 패널 (동적 로딩)
configPanel: React.lazy(() =>
import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({
default: module.TabsConfigPanel
}))
// 설정 패널
configPanel: React.lazy(() =>
import("@/components/screen/config-panels/TabsConfigPanel").then(
(module) => ({
default: module.TabsConfigPanel,
})
)
),
// 검증 함수
validate: (component) => {
const tabsComponent = component as TabsComponent;
const tabsConfig = (component as any).componentConfig || {};
const tabs: TabItem[] = tabsConfig.tabs || [];
const errors: string[] = [];
if (!tabsComponent.tabs || tabsComponent.tabs.length === 0) {
if (!tabs || tabs.length === 0) {
errors.push("최소 1개 이상의 탭이 필요합니다.");
}
if (tabsComponent.tabs) {
const tabIds = tabsComponent.tabs.map((t) => t.id);
if (tabs) {
const tabIds = tabs.map((t) => t.id);
const uniqueIds = new Set(tabIds);
if (tabIds.length !== uniqueIds.size) {
errors.push("탭 ID가 중복되었습니다.");
}
}
return {
isValid: errors.length === 0,
errors,
};
},
});
// console.log("✅ 탭 컴포넌트 등록 완료");

View File

@ -64,7 +64,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"simple-repeater-table": () => import("@/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel"),
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
"repeat-screen-modal": () => import("@/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel"),
"v2-repeat-screen-modal": () => import("@/lib/registry/components/v2-repeat-screen-modal/RepeatScreenModalConfigPanel"),
"related-data-buttons": () => import("@/lib/registry/components/related-data-buttons/RelatedDataButtonsConfigPanel"),
// ========== 검색/선택 ==========

View File

@ -211,14 +211,27 @@ export interface ComponentComponent extends BaseComponent {
/**
*
*/
/**
* ( )
*/
export interface TabInlineComponent {
id: string;
componentType: string; // 컴포넌트 타입 (예: "v2-text-display", "v2-table-list")
label?: string;
position: Position; // 탭 내부에서의 위치
size: Size; // 컴포넌트 크기
componentConfig?: any; // 컴포넌트별 설정
style?: ComponentStyle;
}
export interface TabItem {
id: string;
label: string;
screenId?: number; // 연결된 화면 ID
screenName?: string; // 화면 이름 (표시용)
icon?: string; // 아이콘 (선택사항)
disabled?: boolean; // 비활성화 여부
order: number; // 탭 순서
// 🆕 인라인 컴포넌트 배치
components?: TabInlineComponent[]; // 탭 내부 컴포넌트들
}
/**