Compare commits
2 Commits
d3701cfe1e
...
58d658e638
| Author | SHA1 | Date |
|---|---|---|
|
|
58d658e638 | |
|
|
a67b53038f |
|
|
@ -1044,7 +1044,6 @@
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
|
|
@ -2372,7 +2371,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
|
|
@ -3476,7 +3474,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3713,7 +3710,6 @@
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
|
|
@ -3931,7 +3927,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -4458,7 +4453,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
|
|
@ -7432,7 +7425,6 @@
|
||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
|
|
@ -8402,6 +8394,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -9290,7 +9283,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
|
|
@ -10141,6 +10133,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -10949,7 +10942,6 @@
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
|
@ -11055,7 +11047,6 @@
|
||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ interface RealtimePreviewProps {
|
||||||
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
|
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
|
||||||
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
|
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
|
||||||
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
|
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
|
||||||
|
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
|
||||||
|
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
|
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||||
|
|
||||||
// 버튼 액션을 위한 props
|
// 버튼 액션을 위한 props
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
|
|
@ -133,6 +136,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
conditionalDisabled, // 🆕 조건부 비활성화 상태
|
conditionalDisabled, // 🆕 조건부 비활성화 상태
|
||||||
|
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
|
||||||
|
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
|
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 화면 다국어 컨텍스트
|
// 🆕 화면 다국어 컨텍스트
|
||||||
const { getTranslatedText } = useScreenMultiLang();
|
const { getTranslatedText } = useScreenMultiLang();
|
||||||
|
|
@ -518,6 +524,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
onHeightChange={onHeightChange}
|
onHeightChange={onHeightChange}
|
||||||
conditionalDisabled={conditionalDisabled}
|
conditionalDisabled={conditionalDisabled}
|
||||||
|
onUpdateComponent={onUpdateComponent}
|
||||||
|
onSelectTabComponent={onSelectTabComponent}
|
||||||
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
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(
|
const handleComponentSelect = useCallback(
|
||||||
(component: ComponentData | null) => {
|
(component: ComponentData | null) => {
|
||||||
setSelectedComponent(component);
|
setSelectedComponent(component);
|
||||||
|
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제
|
||||||
|
if (component) {
|
||||||
|
setSelectedTabComponentInfo(null);
|
||||||
|
}
|
||||||
|
|
||||||
// 컴포넌트가 선택되면 통합 패널 자동 열기
|
// 컴포넌트가 선택되면 통합 패널 자동 열기
|
||||||
if (component) {
|
if (component) {
|
||||||
|
|
@ -177,6 +189,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
[openPanel],
|
[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[]>([]);
|
const [clipboard, setClipboard] = useState<ComponentData[]>([]);
|
||||||
|
|
||||||
|
|
@ -380,6 +415,96 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
[historyIndex],
|
[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(() => {
|
const undo = useCallback(() => {
|
||||||
setHistoryIndex((prevIndex) => {
|
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();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
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();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
|
|
@ -4605,24 +4855,125 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||||
<UnifiedPropertiesPanel
|
{/* 🆕 탭 내부 컴포넌트가 선택된 경우 별도 패널 표시 */}
|
||||||
selectedComponent={selectedComponent || undefined}
|
{selectedTabComponentInfo ? (
|
||||||
tables={tables}
|
<div className="flex h-full flex-col p-4">
|
||||||
onUpdateProperty={updateComponentProperty}
|
<div className="mb-4 flex items-center justify-between">
|
||||||
onDeleteComponent={deleteComponent}
|
<div>
|
||||||
onCopyComponent={copyComponent}
|
<h3 className="text-sm font-semibold">탭 내부 컴포넌트 설정</h3>
|
||||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
<p className="text-xs text-muted-foreground">
|
||||||
currentTableName={selectedScreen?.tableName}
|
{selectedTabComponentInfo.component.label || selectedTabComponentInfo.component.componentType}
|
||||||
currentScreenCompanyCode={selectedScreen?.companyCode}
|
</p>
|
||||||
dragState={dragState}
|
</div>
|
||||||
onStyleChange={(style) => {
|
<button
|
||||||
if (selectedComponent) {
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
updateComponentProperty(selectedComponent.id, "style", style);
|
onClick={() => setSelectedTabComponentInfo(null)}
|
||||||
}
|
>
|
||||||
}}
|
선택 해제
|
||||||
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
</button>
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
</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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4952,6 +5303,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
updatedConfig: config,
|
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" ||
|
{(component.type === "group" ||
|
||||||
|
|
|
||||||
|
|
@ -5,70 +5,59 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
Select,
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
SelectContent,
|
||||||
import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react";
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Trash2,
|
||||||
|
Move,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface TabsConfigPanelProps {
|
||||||
config: any;
|
config: any;
|
||||||
onChange: (config: any) => void;
|
onChange: (config: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenInfo {
|
|
||||||
screenId: number;
|
|
||||||
screenName: string;
|
|
||||||
screenCode: string;
|
|
||||||
tableName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [localTabs, setLocalTabs] = useState<TabItem[]>(config.tabs || []);
|
const [localTabs, setLocalTabs] = useState<TabItem[]>(config.tabs || []);
|
||||||
|
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||||
// 화면 목록 로드
|
|
||||||
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 [isUserEditing, setIsUserEditing] = useState(false);
|
const [isUserEditing, setIsUserEditing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 사용자가 입력 중이 아닐 때만 동기화
|
|
||||||
if (!isUserEditing) {
|
if (!isUserEditing) {
|
||||||
setLocalTabs(config.tabs || []);
|
setLocalTabs(config.tabs || []);
|
||||||
}
|
}
|
||||||
}, [config.tabs, isUserEditing]);
|
}, [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 handleAddTab = () => {
|
||||||
const newTab: TabItem = {
|
const newTab: TabItem = {
|
||||||
|
|
@ -76,11 +65,15 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||||
label: `새 탭 ${localTabs.length + 1}`,
|
label: `새 탭 ${localTabs.length + 1}`,
|
||||||
order: localTabs.length,
|
order: localTabs.length,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
components: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedTabs = [...localTabs, newTab];
|
const updatedTabs = [...localTabs, newTab];
|
||||||
setLocalTabs(updatedTabs);
|
setLocalTabs(updatedTabs);
|
||||||
onChange({ ...config, tabs: 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) => {
|
const handleLabelChange = (tabId: string, label: string) => {
|
||||||
setIsUserEditing(true);
|
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);
|
setLocalTabs(updatedTabs);
|
||||||
// onChange는 onBlur에서 호출
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 탭 라벨 변경 완료 (포커스 아웃 시)
|
// 탭 라벨 변경 완료
|
||||||
const handleLabelBlur = () => {
|
const handleLabelBlur = () => {
|
||||||
setIsUserEditing(false);
|
setIsUserEditing(false);
|
||||||
onChange({ ...config, tabs: localTabs });
|
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 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);
|
setLocalTabs(updatedTabs);
|
||||||
onChange({ ...config, tabs: updatedTabs });
|
onChange({ ...config, tabs: updatedTabs });
|
||||||
};
|
};
|
||||||
|
|
@ -130,14 +119,68 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||||
|
|
||||||
const newTabs = [...localTabs];
|
const newTabs = [...localTabs];
|
||||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
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 }));
|
const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx }));
|
||||||
setLocalTabs(updatedTabs);
|
setLocalTabs(updatedTabs);
|
||||||
onChange({ ...config, tabs: 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 (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -193,7 +236,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.persistSelection || false}
|
checked={config.persistSelection || false}
|
||||||
onCheckedChange={(checked) => onChange({ ...config, persistSelection: checked })}
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ ...config, persistSelection: checked })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -207,7 +252,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.allowCloseable || false}
|
checked={config.allowCloseable || false}
|
||||||
onCheckedChange={(checked) => onChange({ ...config, allowCloseable: checked })}
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ ...config, allowCloseable: checked })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,168 +284,157 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{localTabs.map((tab, index) => (
|
{localTabs.map((tab, index) => (
|
||||||
<div
|
<Collapsible
|
||||||
key={tab.id}
|
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="rounded-lg border bg-card shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
{/* 탭 헤더 */}
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center justify-between p-3">
|
||||||
<span className="text-xs font-medium">탭 {index + 1}</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground" />
|
||||||
<div className="flex items-center gap-1">
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||||
onClick={() => handleMoveTab(tab.id, "up")}
|
{expandedTabs.has(tab.id) ? (
|
||||||
disabled={index === 0}
|
<ChevronDown className="h-4 w-4" />
|
||||||
size="sm"
|
) : (
|
||||||
variant="ghost"
|
<ChevronRight className="h-4 w-4" />
|
||||||
className="h-7 w-7 p-0"
|
)}
|
||||||
>
|
</Button>
|
||||||
↑
|
</CollapsibleTrigger>
|
||||||
</Button>
|
<span className="text-xs font-medium">
|
||||||
<Button
|
{tab.label || `탭 ${index + 1}`}
|
||||||
onClick={() => handleMoveTab(tab.id, "down")}
|
</span>
|
||||||
disabled={index === localTabs.length - 1}
|
{tab.components && tab.components.length > 0 && (
|
||||||
size="sm"
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-primary">
|
||||||
variant="ghost"
|
{tab.components.length}개 컴포넌트
|
||||||
className="h-7 w-7 p-0"
|
</span>
|
||||||
>
|
)}
|
||||||
↓
|
</div>
|
||||||
</Button>
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRemoveTab(tab.id)}
|
onClick={() => handleMoveTab(tab.id, "up")}
|
||||||
size="sm"
|
disabled={index === 0}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
variant="ghost"
|
||||||
>
|
className="h-7 w-7 p-0"
|
||||||
<X className="h-3 w-3" />
|
>
|
||||||
</Button>
|
↑
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
|
onClick={() => handleMoveTab(tab.id, "down")}
|
||||||
<div className="space-y-3">
|
disabled={index === localTabs.length - 1}
|
||||||
{/* 탭 라벨 */}
|
size="sm"
|
||||||
<div>
|
variant="ghost"
|
||||||
<Label className="text-xs">탭 라벨</Label>
|
className="h-7 w-7 p-0"
|
||||||
<Input
|
>
|
||||||
value={tab.label}
|
↓
|
||||||
onChange={(e) => handleLabelChange(tab.id, e.target.value)}
|
</Button>
|
||||||
onBlur={handleLabelBlur}
|
<Button
|
||||||
placeholder="탭 이름"
|
onClick={() => handleRemoveTab(tab.id)}
|
||||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
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>
|
||||||
|
|
||||||
{/* 화면 선택 */}
|
{/* 탭 컨텐츠 */}
|
||||||
<div>
|
<CollapsibleContent>
|
||||||
<Label className="text-xs">연결된 화면</Label>
|
<div className="space-y-4 border-t p-3">
|
||||||
{loading ? (
|
{/* 탭 라벨 */}
|
||||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
<div>
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Label className="text-xs">탭 라벨</Label>
|
||||||
<span className="text-muted-foreground text-xs">로딩 중...</span>
|
<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>
|
</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">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">비활성화</Label>
|
<Label className="text-xs">비활성화</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={tab.disabled || false}
|
checked={tab.disabled || false}
|
||||||
onCheckedChange={(checked) => handleDisabledToggle(tab.id, checked)}
|
onCheckedChange={(checked) =>
|
||||||
/>
|
handleDisabledToggle(tab.id, checked)
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
</div>
|
</Collapsible>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export function ComponentsPanel({
|
||||||
"pivot-grid", // → v2-pivot-grid
|
"pivot-grid", // → v2-pivot-grid
|
||||||
"table-search-widget", // → v2-table-search-widget
|
"table-search-widget", // → v2-table-search-widget
|
||||||
"tabs", // → v2-tabs
|
"tabs", // → v2-tabs
|
||||||
|
"tabs-widget", // → v2-tabs-widget
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,37 @@
|
||||||
"use client";
|
"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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2 } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||||
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
|
||||||
interface TabsWidgetProps {
|
interface TabsWidgetProps {
|
||||||
component: TabsComponent;
|
component: TabsComponent;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
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) {
|
export function TabsWidget({
|
||||||
// ActiveTab context 사용
|
component,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
menuObjid,
|
||||||
|
formData = {},
|
||||||
|
onFormDataChange,
|
||||||
|
isDesignMode = false,
|
||||||
|
onComponentSelect,
|
||||||
|
selectedComponentId,
|
||||||
|
}: TabsWidgetProps) {
|
||||||
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
||||||
const {
|
const {
|
||||||
tabs = [],
|
tabs = [],
|
||||||
|
|
@ -28,7 +42,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
persistSelection = false,
|
persistSelection = false,
|
||||||
} = component;
|
} = component;
|
||||||
|
|
||||||
|
|
||||||
const storageKey = `tabs-${component.id}-selected`;
|
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 [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
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()]));
|
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") {
|
if (persistSelection && typeof window !== "undefined") {
|
||||||
localStorage.setItem(storageKey, selectedTab);
|
localStorage.setItem(storageKey, selectedTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveTab Context에 현재 활성 탭 정보 등록
|
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
|
||||||
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
|
|
||||||
if (currentTabInfo) {
|
if (currentTabInfo) {
|
||||||
setActiveTab(component.id, {
|
setActiveTab(component.id, {
|
||||||
tabId: selectedTab,
|
tabId: selectedTab,
|
||||||
tabsComponentId: component.id,
|
tabsComponentId: component.id,
|
||||||
screenId: currentTabInfo.screenId,
|
|
||||||
label: currentTabInfo.label,
|
label: currentTabInfo.label,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -79,53 +87,16 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
};
|
};
|
||||||
}, [component.id, removeTabsComponent]);
|
}, [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) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
setSelectedTab(tabId);
|
setSelectedTab(tabId);
|
||||||
|
|
||||||
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
setMountedTabs((prev) => {
|
||||||
setMountedTabs(prev => {
|
|
||||||
if (prev.has(tabId)) return prev;
|
if (prev.has(tabId)) return prev;
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.add(tabId);
|
newSet.add(tabId);
|
||||||
return newSet;
|
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);
|
const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId);
|
||||||
setVisibleTabs(updatedTabs);
|
setVisibleTabs(updatedTabs);
|
||||||
|
|
||||||
// 닫은 탭이 선택된 탭이었다면 다음 탭 선택
|
|
||||||
if (selectedTab === tabId && updatedTabs.length > 0) {
|
if (selectedTab === tabId && updatedTabs.length > 0) {
|
||||||
setSelectedTab(updatedTabs[0].id);
|
setSelectedTab(updatedTabs[0].id);
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +123,68 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
return `${baseClass} ${variantClass}`;
|
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) {
|
if (visibleTabs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
<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 (
|
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
|
<Tabs
|
||||||
value={selectedTab}
|
value={selectedTab}
|
||||||
onValueChange={handleTabChange}
|
onValueChange={handleTabChange}
|
||||||
|
|
@ -175,6 +207,11 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
<div key={tab.id} className="relative">
|
<div key={tab.id} className="relative">
|
||||||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.components && tab.components.length > 0 && (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
({tab.components.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{allowCloseable && (
|
{allowCloseable && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -191,86 +228,19 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{visibleTabs.map((tab) => {
|
{visibleTabs.map((tab) => {
|
||||||
// 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩)
|
|
||||||
const shouldRender = mountedTabs.has(tab.id);
|
const shouldRender = mountedTabs.has(tab.id);
|
||||||
const isActive = selectedTab === tab.id;
|
const isActive = selectedTab === tab.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
value={tab.id}
|
value={tab.id}
|
||||||
forceMount // 🆕 DOM에 항상 유지
|
forceMount
|
||||||
className={cn(
|
className={cn("h-full", !isActive && "hidden")}
|
||||||
"h-full",
|
|
||||||
!isActive && "hidden" // 🆕 비활성 탭은 CSS로 숨김
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* 한 번 마운트된 탭만 내용 렌더링 */}
|
{shouldRender && renderTabComponents(tab)}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,11 @@ export interface DynamicComponentRendererProps {
|
||||||
tableDisplayData?: any[]; // 🆕 화면 표시 데이터
|
tableDisplayData?: any[]; // 🆕 화면 표시 데이터
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
|
// 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등)
|
||||||
|
onUpdateComponent?: (updatedComponent: any) => void;
|
||||||
|
// 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
|
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void;
|
||||||
|
selectedTabComponentId?: string;
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
||||||
// 테이블 새로고침 키
|
// 테이블 새로고침 키
|
||||||
|
|
@ -754,6 +759,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
||||||
parentTabId: props.parentTabId,
|
parentTabId: props.parentTabId,
|
||||||
parentTabsComponentId: props.parentTabsComponentId,
|
parentTabsComponentId: props.parentTabsComponentId,
|
||||||
|
// 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등)
|
||||||
|
onUpdateComponent: props.onUpdateComponent,
|
||||||
|
// 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
|
onSelectTabComponent: props.onSelectTabComponent,
|
||||||
|
selectedTabComponentId: props.selectedTabComponentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,6 @@ import "./v2-numbering-rule/NumberingRuleRenderer";
|
||||||
import "./v2-table-list/TableListRenderer";
|
import "./v2-table-list/TableListRenderer";
|
||||||
import "./v2-text-display/TextDisplayRenderer";
|
import "./v2-text-display/TextDisplayRenderer";
|
||||||
import "./v2-pivot-grid/PivotGridRenderer";
|
import "./v2-pivot-grid/PivotGridRenderer";
|
||||||
import "./v2-repeat-screen-modal/RepeatScreenModalRenderer";
|
|
||||||
import "./v2-divider-line/DividerLineRenderer";
|
import "./v2-divider-line/DividerLineRenderer";
|
||||||
import "./v2-repeat-container/RepeatContainerRenderer";
|
import "./v2-repeat-container/RepeatContainerRenderer";
|
||||||
import "./v2-section-card/SectionCardRenderer";
|
import "./v2-section-card/SectionCardRenderer";
|
||||||
|
|
|
||||||
|
|
@ -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 분리
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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 {};
|
|
||||||
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -5373,7 +5373,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
{/* 🆕 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">
|
<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">
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||||||
<Button
|
<Button
|
||||||
variant={editMode === "batch" ? "default" : "ghost"}
|
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">
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||||||
{(tableConfig.toolbar?.showExcel ?? true) && (
|
{(tableConfig.toolbar?.showExcel ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -5403,7 +5403,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
Excel
|
Excel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(tableConfig.toolbar?.showPdf ?? true) && (
|
{(tableConfig.toolbar?.showPdf ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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">
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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">
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||||||
{isSearchPanelOpen ? (
|
{isSearchPanelOpen ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -5529,7 +5529,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
|
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
|
||||||
{(tableConfig.toolbar?.showFilter ?? true) && (
|
{(tableConfig.toolbar?.showFilter ?? false) && (
|
||||||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||||||
<Button
|
<Button
|
||||||
variant={activeFilterCount > 0 ? "default" : "ghost"}
|
variant={activeFilterCount > 0 ? "default" : "ghost"}
|
||||||
|
|
@ -5558,8 +5558,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 새로고침 */}
|
{/* 새로고침 (상단) */}
|
||||||
{(tableConfig.toolbar?.showRefresh ?? true) && (
|
{(tableConfig.toolbar?.showRefresh ?? false) && (
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -771,7 +771,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showEditMode"
|
id="showEditMode"
|
||||||
checked={config.toolbar?.showEditMode ?? true}
|
checked={config.toolbar?.showEditMode ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showEditMode" className="text-xs">즉시 저장</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showExcel"
|
id="showExcel"
|
||||||
checked={config.toolbar?.showExcel ?? true}
|
checked={config.toolbar?.showExcel ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showExcel" className="text-xs">Excel</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showPdf"
|
id="showPdf"
|
||||||
checked={config.toolbar?.showPdf ?? true}
|
checked={config.toolbar?.showPdf ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showPdf" className="text-xs">PDF</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showCopy"
|
id="showCopy"
|
||||||
checked={config.toolbar?.showCopy ?? true}
|
checked={config.toolbar?.showCopy ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showCopy" className="text-xs">복사</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showSearch"
|
id="showSearch"
|
||||||
checked={config.toolbar?.showSearch ?? true}
|
checked={config.toolbar?.showSearch ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showSearch" className="text-xs">검색</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showFilter"
|
id="showFilter"
|
||||||
checked={config.toolbar?.showFilter ?? true}
|
checked={config.toolbar?.showFilter ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showFilter" className="text-xs">필터</Label>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showRefresh"
|
id="showRefresh"
|
||||||
checked={config.toolbar?.showRefresh ?? true}
|
checked={config.toolbar?.showRefresh ?? false}
|
||||||
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showRefresh" className="text-xs">새로고침 (상단)</Label>
|
<Label htmlFor="showRefresh" className="text-xs">새로고침 (상단)</Label>
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,16 @@ export const V2TableListDefinition = createComponentDefinition({
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
// 표시 모드 설정
|
// 표시 모드 설정
|
||||||
displayMode: "table" as const,
|
displayMode: "table" as const,
|
||||||
|
|
||||||
// 카드 모드 기본 설정
|
// 카드 모드 기본 설정
|
||||||
cardConfig: {
|
cardConfig: {
|
||||||
idColumn: "id",
|
idColumn: "id",
|
||||||
titleColumn: "name",
|
titleColumn: "name",
|
||||||
cardsPerRow: 3,
|
cardsPerRow: 3,
|
||||||
cardSpacing: 16,
|
cardSpacing: 16,
|
||||||
showActions: true,
|
showActions: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 테이블 기본 설정
|
// 테이블 기본 설정
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
showFooter: true,
|
showFooter: true,
|
||||||
|
|
@ -93,6 +93,18 @@ export const V2TableListDefinition = createComponentDefinition({
|
||||||
borderStyle: "light",
|
borderStyle: "light",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 툴바 설정 (기본값: 새로고침 하단만 활성화)
|
||||||
|
toolbar: {
|
||||||
|
showEditMode: false,
|
||||||
|
showExcel: false,
|
||||||
|
showPdf: false,
|
||||||
|
showCopy: false,
|
||||||
|
showSearch: false,
|
||||||
|
showFilter: false,
|
||||||
|
showRefresh: false,
|
||||||
|
showPaginationRefresh: true, // 새로고침 (하단)만 기본 활성화
|
||||||
|
},
|
||||||
|
|
||||||
// 데이터 로딩
|
// 데이터 로딩
|
||||||
autoLoad: true,
|
autoLoad: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,357 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import { Folder } from "lucide-react";
|
import { Folder, Plus, Move, Settings, Trash2 } from "lucide-react";
|
||||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
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 래퍼 컴포넌트
|
// TabsWidget 래퍼 컴포넌트
|
||||||
const TabsWidgetWrapper: React.FC<any> = (props) => {
|
const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
const { component, ...restProps } = props;
|
const {
|
||||||
|
component,
|
||||||
|
isDesignMode,
|
||||||
|
onUpdateComponent,
|
||||||
|
onSelectTabComponent,
|
||||||
|
selectedTabComponentId,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
// componentConfig에서 탭 정보 추출
|
// componentConfig에서 탭 정보 추출
|
||||||
const tabsConfig = component.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 = {
|
const tabsComponent = {
|
||||||
...component,
|
...component,
|
||||||
type: "tabs" as const,
|
type: "tabs" as const,
|
||||||
tabs: tabsConfig.tabs || [],
|
tabs: tabs,
|
||||||
defaultTab: tabsConfig.defaultTab,
|
defaultTab: tabsConfig.defaultTab,
|
||||||
orientation: tabsConfig.orientation || "horizontal",
|
orientation: tabsConfig.orientation || "horizontal",
|
||||||
variant: tabsConfig.variant || "default",
|
variant: tabsConfig.variant || "default",
|
||||||
|
|
@ -23,10 +359,9 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
persistSelection: tabsConfig.persistSelection || false,
|
persistSelection: tabsConfig.persistSelection || false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TabsWidget =
|
||||||
|
require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||||
|
|
||||||
// TabsWidget 동적 로드
|
|
||||||
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<TabsWidget component={tabsComponent} {...restProps} />
|
<TabsWidget component={tabsComponent} {...restProps} />
|
||||||
|
|
@ -36,26 +371,49 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 탭 컴포넌트 정의
|
* 탭 컴포넌트 정의
|
||||||
*
|
*
|
||||||
* 여러 화면을 탭으로 구분하여 전환할 수 있는 컴포넌트
|
* 탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트
|
||||||
*/
|
*/
|
||||||
ComponentRegistry.registerComponent({
|
ComponentRegistry.registerComponent({
|
||||||
id: "v2-tabs-widget",
|
id: "v2-tabs-widget",
|
||||||
name: "탭 컴포넌트",
|
name: "탭 컴포넌트",
|
||||||
description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.",
|
description:
|
||||||
|
"탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트입니다.",
|
||||||
category: ComponentCategory.LAYOUT,
|
category: ComponentCategory.LAYOUT,
|
||||||
webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값
|
webType: "text" as any,
|
||||||
component: TabsWidgetWrapper, // ✅ 실제 TabsWidget 렌더러
|
component: TabsWidgetWrapper,
|
||||||
defaultConfig: {},
|
defaultConfig: {
|
||||||
tags: ["tabs", "navigation", "layout", "screen"],
|
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,
|
icon: Folder,
|
||||||
version: "1.0.0",
|
version: "2.0.0",
|
||||||
|
|
||||||
defaultSize: {
|
defaultSize: {
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
},
|
},
|
||||||
|
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
type: "tabs" as const,
|
type: "tabs" as const,
|
||||||
tabs: [
|
tabs: [
|
||||||
|
|
@ -64,12 +422,14 @@ ComponentRegistry.registerComponent({
|
||||||
label: "탭 1",
|
label: "탭 1",
|
||||||
order: 0,
|
order: 0,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
components: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tab-2",
|
id: "tab-2",
|
||||||
label: "탭 2",
|
label: "탭 2",
|
||||||
order: 1,
|
order: 1,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
components: [],
|
||||||
},
|
},
|
||||||
] as TabItem[],
|
] as TabItem[],
|
||||||
defaultTab: "tab-1",
|
defaultTab: "tab-1",
|
||||||
|
|
@ -78,82 +438,167 @@ ComponentRegistry.registerComponent({
|
||||||
allowCloseable: false,
|
allowCloseable: false,
|
||||||
persistSelection: false,
|
persistSelection: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 에디터 모드에서의 렌더링
|
// 에디터 모드에서의 렌더링 - 탭 선택 및 컴포넌트 드롭 지원
|
||||||
renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, children }) => {
|
renderEditor: ({
|
||||||
const tabsComponent = component as TabsComponent;
|
component,
|
||||||
const tabs = tabsComponent.tabs || [];
|
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 (
|
return (
|
||||||
<div
|
<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}
|
onClick={onClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
{/* 탭 헤더 */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center border-b bg-muted/30">
|
||||||
<Folder className="h-8 w-8 text-gray-400" />
|
{tabs.length > 0 ? (
|
||||||
</div>
|
tabs.map((tab) => (
|
||||||
<p className="text-muted-foreground mt-2 text-sm font-medium">탭 컴포넌트</p>
|
<div
|
||||||
<p className="text-xs text-gray-400">
|
key={tab.id}
|
||||||
{tabs.length > 0
|
className={getTabStyle(tab)}
|
||||||
? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)`
|
onClick={(e) => {
|
||||||
: "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}
|
e.stopPropagation();
|
||||||
</p>
|
setActiveTabId(tab.id);
|
||||||
{tabs.length > 0 && (
|
}}
|
||||||
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
>
|
||||||
{tabs.map((tab: TabItem, index: number) => (
|
{tab.label || "탭"}
|
||||||
<span
|
{tab.components && tab.components.length > 0 && (
|
||||||
key={tab.id}
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
className="rounded-md border bg-white px-2 py-1 text-xs"
|
({tab.components.length})
|
||||||
>
|
</span>
|
||||||
{tab.label || `탭 ${index + 1}`}
|
)}
|
||||||
</span>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
탭이 없습니다
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 인터랙티브 모드에서의 렌더링 (실제 동작)
|
// 인터랙티브 모드에서의 렌더링
|
||||||
renderInteractive: ({ component }) => {
|
renderInteractive: ({ component }) => {
|
||||||
// InteractiveScreenViewer에서 TabsWidget을 사용하므로 여기서는 null 반환
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 설정 패널 (동적 로딩)
|
// 설정 패널
|
||||||
configPanel: React.lazy(() =>
|
configPanel: React.lazy(() =>
|
||||||
import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({
|
import("@/components/screen/config-panels/TabsConfigPanel").then(
|
||||||
default: module.TabsConfigPanel
|
(module) => ({
|
||||||
}))
|
default: module.TabsConfigPanel,
|
||||||
|
})
|
||||||
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// 검증 함수
|
// 검증 함수
|
||||||
validate: (component) => {
|
validate: (component) => {
|
||||||
const tabsComponent = component as TabsComponent;
|
const tabsConfig = (component as any).componentConfig || {};
|
||||||
|
const tabs: TabItem[] = tabsConfig.tabs || [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!tabsComponent.tabs || tabsComponent.tabs.length === 0) {
|
if (!tabs || tabs.length === 0) {
|
||||||
errors.push("최소 1개 이상의 탭이 필요합니다.");
|
errors.push("최소 1개 이상의 탭이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabsComponent.tabs) {
|
if (tabs) {
|
||||||
const tabIds = tabsComponent.tabs.map((t) => t.id);
|
const tabIds = tabs.map((t) => t.id);
|
||||||
const uniqueIds = new Set(tabIds);
|
const uniqueIds = new Set(tabIds);
|
||||||
if (tabIds.length !== uniqueIds.size) {
|
if (tabIds.length !== uniqueIds.size) {
|
||||||
errors.push("탭 ID가 중복되었습니다.");
|
errors.push("탭 ID가 중복되었습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log("✅ 탭 컴포넌트 등록 완료");
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
"simple-repeater-table": () => import("@/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel"),
|
"simple-repeater-table": () => import("@/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel"),
|
||||||
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
|
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
|
||||||
"repeat-screen-modal": () => import("@/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel"),
|
"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"),
|
"related-data-buttons": () => import("@/lib/registry/components/related-data-buttons/RelatedDataButtonsConfigPanel"),
|
||||||
|
|
||||||
// ========== 검색/선택 ==========
|
// ========== 검색/선택 ==========
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export interface TabItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
screenId?: number; // 연결된 화면 ID
|
|
||||||
screenName?: string; // 화면 이름 (표시용)
|
|
||||||
icon?: string; // 아이콘 (선택사항)
|
icon?: string; // 아이콘 (선택사항)
|
||||||
disabled?: boolean; // 비활성화 여부
|
disabled?: boolean; // 비활성화 여부
|
||||||
order: number; // 탭 순서
|
order: number; // 탭 순서
|
||||||
|
// 🆕 인라인 컴포넌트 배치
|
||||||
|
components?: TabInlineComponent[]; // 탭 내부 컴포넌트들
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue