From f7384cb450fdcb2e2820d9f5d3d436c3fcd3ce10 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 15 Dec 2025 09:25:14 +0900 Subject: [PATCH 01/29] =?UTF-8?q?fix(modal-repeater-table):=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20ID=20=ED=83=80=EC=9E=85=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/registry/components/split-panel-layout2/README.md | 1 + .../components/split-panel-layout2/SplitPanelLayout2Renderer.tsx | 1 + .../universal-form-modal/modals/FieldDetailSettingsModal.tsx | 1 + .../components/universal-form-modal/modals/SaveSettingsModal.tsx | 1 + .../universal-form-modal/modals/SectionLayoutModal.tsx | 1 + 5 files changed, 5 insertions(+) diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md index f1d8544b..4e5debe8 100644 --- a/frontend/lib/registry/components/split-panel-layout2/README.md +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -100,3 +100,4 @@ - [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) - [split-panel-layout (v1)](../split-panel-layout/README.md) + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx index f582646e..21e70b13 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -40,3 +40,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer // 자동 등록 실행 SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index 719a99e3..751ac2c6 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -840,3 +840,4 @@ export function FieldDetailSettingsModal({ ); } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 9d269c62..27ee00ff 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -794,3 +794,4 @@ export function SaveSettingsModal({ ); } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index fe981260..dfdecbc0 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -514,3 +514,4 @@ export function SectionLayoutModal({ ); } + From 95cbd62b1aaacb40d69d751ab9171aaf67d60e23 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 09:46:26 +0900 Subject: [PATCH 02/29] =?UTF-8?q?3D=20=EC=95=BC=EB=93=9C=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 361 ++++++++++-------- .../widgets/yard-3d/DigitalTwinViewer.tsx | 347 +++++++++-------- 2 files changed, 379 insertions(+), 329 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index b99b58af..f511a7b1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,7 +2,19 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react"; +import { + ArrowLeft, + Save, + Loader2, + Grid3x3, + Move, + Box, + Package, + Truck, + Check, + ParkingCircle, + RefreshCw, +} from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -78,7 +90,7 @@ const DebouncedInput = ({ const handleBlur = (e: React.FocusEvent) => { setIsEditing(false); if (onCommit && debounce === 0) { - // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, + // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) onCommit(type === "number" ? parseFloat(localValue as string) : localValue); } @@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 레이아웃 데이터 로드 const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); + const [isRefreshing, setIsRefreshing] = useState(false); - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; - setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + if (response.success && response.data) { + const { layout, objects } = response.data; + setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 - // 외부 DB 연결 ID 복원 - if (layout.external_db_connection_id) { - setSelectedDbConnection(layout.external_db_connection_id); - } - - // 계층 구조 설정 로드 - if (layout.hierarchy_config) { - try { - // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 - const config = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(config); - - // 선택된 테이블 정보도 복원 - const newSelectedTables: any = { - warehouse: config.warehouse?.tableName || "", - area: "", - location: "", - material: "", - }; - - if (config.levels && config.levels.length > 0) { - // 레벨 1 = Area - if (config.levels[0]?.tableName) { - newSelectedTables.area = config.levels[0].tableName; - } - // 레벨 2 = Location - if (config.levels[1]?.tableName) { - newSelectedTables.location = config.levels[1].tableName; - } - } - - // 자재 테이블 정보 - if (config.material?.tableName) { - newSelectedTables.material = config.material.tableName; - } - - setSelectedTables(newSelectedTables); - } catch (e) { - console.error("계층 구조 설정 파싱 실패:", e); - } - } - - // 객체 데이터 변환 (DB -> PlacedObject) - const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ - id: obj.id, - type: obj.object_type, - name: obj.object_name, - position: { - x: parseFloat(obj.position_x), - y: parseFloat(obj.position_y), - z: parseFloat(obj.position_z), - }, - size: { - x: parseFloat(obj.size_x), - y: parseFloat(obj.size_y), - z: parseFloat(obj.size_z), - }, - rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: obj.color, - areaKey: obj.area_key, - locaKey: obj.loca_key, - locType: obj.loc_type, - materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, - materialPreview: - obj.loc_type === "STP" || !obj.material_preview_height - ? undefined - : { height: parseFloat(obj.material_preview_height) }, - parentId: obj.parent_id, - displayOrder: obj.display_order, - locked: obj.locked, - visible: obj.visible !== false, - hierarchyLevel: obj.hierarchy_level || 1, - parentKey: obj.parent_key, - externalKey: obj.external_key, - })); - - setPlacedObjects(loadedObjects); - - // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) - const minId = Math.min(...loadedObjects.map((o) => o.id), 0); - setNextObjectId(minId - 1); - - setHasUnsavedChanges(false); - - toast({ - title: "레이아웃 불러오기 완료", - description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, - }); - - // Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) - const dbConnectionId = layout.external_db_connection_id; - const hierarchyConfigParsed = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - const materialTableName = hierarchyConfigParsed?.material?.tableName; - - const locationObjects = loadedObjects.filter( - (obj) => - (obj.type === "location-bed" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && - obj.locaKey, - ); - if (locationObjects.length > 0 && dbConnectionId && materialTableName) { - const locaKeys = locationObjects.map((obj) => obj.locaKey!); - setTimeout(() => { - loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); - }, 100); - } - } else { - throw new Error(response.error || "레이아웃 조회 실패"); + // 외부 DB 연결 ID 복원 + if (layout.external_db_connection_id) { + setSelectedDbConnection(layout.external_db_connection_id); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); - } - }; + // 계층 구조 설정 로드 + if (layout.hierarchy_config) { + try { + // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 + const config = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(config); + + // 선택된 테이블 정보도 복원 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + // 자재 테이블 정보 + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + } catch (e) { + console.error("계층 구조 설정 파싱 실패:", e); + } + } + + // 객체 데이터 변환 (DB -> PlacedObject) + const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + id: obj.id, + type: obj.object_type, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: obj.color, + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level || 1, + parentKey: obj.parent_key, + externalKey: obj.external_key, + })); + + setPlacedObjects(loadedObjects); + + // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) + const minId = Math.min(...loadedObjects.map((o) => o.id), 0); + setNextObjectId(minId - 1); + + setHasUnsavedChanges(false); + + toast({ + title: "레이아웃 불러오기 완료", + description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, + }); + + // Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) + const dbConnectionId = layout.external_db_connection_id; + const hierarchyConfigParsed = + typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; + const materialTableName = hierarchyConfigParsed?.material?.tableName; + + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey, + ); + if (locationObjects.length > 0 && dbConnectionId && materialTableName) { + const locaKeys = locationObjects.map((obj) => obj.locaKey!); + setTimeout(() => { + loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); + }, 100); + } + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + if (hasUnsavedChanges) { + const confirmed = window.confirm( + "저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?", + ); + if (!confirmed) return; + } + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 + }, [layoutId]); // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) useEffect(() => { @@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) - const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { + const loadMaterialCountsForLocations = async ( + locaKeys: string[], + dbConnectionId?: number, + materialTableName?: string, + ) => { const connectionId = dbConnectionId || selectedDbConnection; const tableName = materialTableName || selectedTables.material; if (!connectionId || locaKeys.length === 0) return; @@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await getMaterialCounts(connectionId, tableName, locaKeys); console.log("📊 자재 개수 API 응답:", response); - + if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => @@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) const materialCount = response.data?.find( - (mc: any) => - mc.LOCAKEY === obj.locaKey || - mc.location_key === obj.locaKey || - mc.locakey === obj.locaKey + (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey, ); if (materialCount) { // count 또는 material_count 필드 사용 @@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{hasUnsavedChanges && 미저장 변경사항 있음} +
- setSelectedTemplateId(val)}> {mappingTemplates.length === 0 ? ( -
- 사용 가능한 템플릿이 없습니다 -
+
사용 가능한 템플릿이 없습니다
) : ( mappingTemplates.map((tpl) => (
{tpl.name} {tpl.description && ( - - {tpl.description} - + {tpl.description} )}
@@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }} onLoadColumns={async (tableName: string) => { try { - const response = await ExternalDbConnectionAPI.getTableColumns( - selectedDbConnection, - tableName, - ); + const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); if (response.success && response.data) { // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ - column_name: - typeof col === "string" - ? col - : col.column_name || col.COLUMN_NAME || String(col), + column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), data_type: col.data_type || col.DATA_TYPE, description: col.description || col.COLUMN_COMMENT || undefined, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, @@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi > 취소 - diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 91804987..71462ebe 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 검색 및 필터 const [searchQuery, setSearchQuery] = useState(""); const [filterType, setFilterType] = useState("all"); + const [isRefreshing, setIsRefreshing] = useState(false); - // 레이아웃 데이터 로드 - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 데이터 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; + if (response.success && response.data) { + const { layout, objects } = response.data; - // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) - setLayoutName(layout.layout_name || layout.layoutName); - const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; - setExternalDbConnectionId(dbConnectionId); + // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) + setLayoutName(layout.layout_name || layout.layoutName); + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + setExternalDbConnectionId(dbConnectionId); - // hierarchy_config 저장 - let hierarchyConfigData: any = null; - if (layout.hierarchy_config) { - hierarchyConfigData = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(hierarchyConfigData); - } + // hierarchy_config 저장 + let hierarchyConfigData: any = null; + if (layout.hierarchy_config) { + hierarchyConfigData = + typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; + setHierarchyConfig(hierarchyConfigData); + } - // 객체 데이터 변환 - const loadedObjects: PlacedObject[] = objects.map((obj: any) => { - const objectType = obj.object_type; - return { - id: obj.id, - type: objectType, - name: obj.object_name, - position: { - x: parseFloat(obj.position_x), - y: parseFloat(obj.position_y), - z: parseFloat(obj.position_z), - }, - size: { - x: parseFloat(obj.size_x), - y: parseFloat(obj.size_y), - z: parseFloat(obj.size_z), - }, - rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 - areaKey: obj.area_key, - locaKey: obj.loca_key, - locType: obj.loc_type, - materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, - materialPreview: - obj.loc_type === "STP" || !obj.material_preview_height - ? undefined - : { height: parseFloat(obj.material_preview_height) }, - parentId: obj.parent_id, - displayOrder: obj.display_order, - locked: obj.locked, - visible: obj.visible !== false, - hierarchyLevel: obj.hierarchy_level, - parentKey: obj.parent_key, - externalKey: obj.external_key, - }; + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => { + const objectType = obj.object_type; + return { + id: obj.id, + type: objectType, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, + }; + }); + + setPlacedObjects(loadedObjects); + + // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 + if (dbConnectionId && hierarchyConfigData?.material) { + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && + obj.locaKey, + ); + + // 각 Location에 대해 자재 개수 조회 (병렬 처리) + const materialCountPromises = locationObjects.map(async (obj) => { + try { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: obj.locaKey!, + }); + if (matResponse.success && matResponse.data) { + return { id: obj.id, count: matResponse.data.length }; + } + } catch (e) { + console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e); + } + return { id: obj.id, count: 0 }; }); - setPlacedObjects(loadedObjects); + const materialCounts = await Promise.all(materialCountPromises); - // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 - if (dbConnectionId && hierarchyConfigData?.material) { - const locationObjects = loadedObjects.filter( - (obj) => - (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && - obj.locaKey - ); - - // 각 Location에 대해 자재 개수 조회 (병렬 처리) - const materialCountPromises = locationObjects.map(async (obj) => { - try { - const matResponse = await getMaterials(dbConnectionId, { - tableName: hierarchyConfigData.material.tableName, - keyColumn: hierarchyConfigData.material.keyColumn, - locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, - layerColumn: hierarchyConfigData.material.layerColumn, - locaKey: obj.locaKey!, - }); - if (matResponse.success && matResponse.data) { - return { id: obj.id, count: matResponse.data.length }; - } - } catch (e) { - console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e); + // materialCount 업데이트 + setPlacedObjects((prev) => + prev.map((obj) => { + const countData = materialCounts.find((m) => m.id === obj.id); + if (countData && countData.count > 0) { + return { ...obj, materialCount: countData.count }; } - return { id: obj.id, count: 0 }; - }); - - const materialCounts = await Promise.all(materialCountPromises); - - // materialCount 업데이트 - setPlacedObjects((prev) => - prev.map((obj) => { - const countData = materialCounts.find((m) => m.id === obj.id); - if (countData && countData.count > 0) { - return { ...obj, materialCount: countData.count }; - } - return obj; - }) - ); - } - } else { - throw new Error(response.error || "레이아웃 조회 실패"); + return obj; + }), + ); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); + } else { + throw new Error(response.error || "레이아웃 조회 실패"); } - }; + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + setShowInfoPanel(false); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 - 무한 루프 방지 + }, [layoutId]); // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { @@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

읽기 전용 뷰

+ {/* 메인 영역 */} @@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // Area가 없으면 기존 평면 리스트 유지 if (areaObjects.length === 0) { return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
-
-
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

- )} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
+ ); + })}
); - })} -
- ); } // Area가 있는 경우: Area → Location 계층 아코디언 @@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) />

- 위치: ({locationObj.position.x.toFixed(1)},{" "} - {locationObj.position.z.toFixed(1)}) + 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})

{locationObj.locaKey && (

From 23a1dd6321c2d78ec31b67e7ae57f72268209cb2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 11:17:10 +0900 Subject: [PATCH 03/29] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=EC=84=9C=20=EC=85=80=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=ED=96=89=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9E=90=EB=8F=99=20=ED=95=84=ED=84=B0=EB=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 130 +++++++++++++++--- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index b722e31c..651675b8 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1506,6 +1506,7 @@ export const TableListComponent: React.FC = ({ tableName: tableConfig.selectedTable, selectedLeftData: splitPanelContext?.selectedLeftData, linkedFilters: splitPanelContext?.linkedFilters, + splitPanelPosition: splitPanelPosition, }); if (splitPanelContext) { @@ -1537,6 +1538,39 @@ export const TableListComponent: React.FC = ({ linkedFilterValues[key] = value; } } + + // 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도 + // 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면 + // 동일한 컬럼명이 있는 경우 자동으로 필터링 적용 + if ( + splitPanelPosition === "right" && + hasSelectedLeftData && + Object.keys(linkedFilterValues).length === 0 && + !hasLinkedFiltersConfigured + ) { + const leftData = splitPanelContext.selectedLeftData!; + const tableColumns = (tableConfig.columns || []).map((col) => col.columnName); + + // 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인 + for (const [colName, colValue] of Object.entries(leftData)) { + // null, undefined, 빈 문자열 제외 + if (colValue === null || colValue === undefined || colValue === "") continue; + // id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명) + if (colName === "id" || colName === "objid" || colName === "company_code") continue; + + // 현재 테이블에 동일한 컬럼이 있는지 확인 + if (tableColumns.includes(colName)) { + linkedFilterValues[colName] = colValue; + hasLinkedFiltersConfigured = true; + console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`); + } + } + + if (Object.keys(linkedFilterValues).length > 0) { + console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues); + } + } + if (Object.keys(linkedFilterValues).length > 0) { console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); } @@ -1749,7 +1783,10 @@ export const TableListComponent: React.FC = ({ searchTerm, searchValues, isDesignMode, - splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회 + // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요) + splitPanelPosition, + currentSplitPosition, + splitPanelContext?.selectedLeftData, ]); const fetchTableDataDebounced = useCallback( @@ -2059,7 +2096,18 @@ export const TableListComponent: React.FC = ({ // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) + const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; + + console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", { + splitPanelPosition, + currentSplitPosition, + effectiveSplitPosition, + hasSplitPanelContext: !!splitPanelContext, + disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer, + }); + + if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (!isCurrentlySelected) { // 선택된 경우: 데이터 저장 splitPanelContext.setSelectedLeftData(row); @@ -2077,12 +2125,57 @@ export const TableListComponent: React.FC = ({ console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; - // 🆕 셀 클릭 핸들러 (포커스 설정) + // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); setFocusedCell({ rowIndex, colIndex }); // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) tableContainerRef.current?.focus(); + + // 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리 + // filteredData에서 해당 행의 데이터 가져오기 + const row = filteredData[rowIndex]; + if (!row) return; + + const rowKey = getRowKey(row, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + + // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 + const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; + + console.log("🔗 [TableList] 셀 클릭 - 분할 패널 위치 확인:", { + rowIndex, + colIndex, + splitPanelPosition, + currentSplitPosition, + effectiveSplitPosition, + hasSplitPanelContext: !!splitPanelContext, + isCurrentlySelected, + }); + + if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 + if (!isCurrentlySelected) { + // 기존 선택 해제하고 새 행 선택 + setSelectedRows(new Set([rowKey])); + setIsAllSelected(false); + + // 분할 패널 컨텍스트에 데이터 저장 + splitPanelContext.setSelectedLeftData(row); + console.log("🔗 [TableList] 셀 클릭으로 분할 패널 좌측 데이터 저장:", { + row, + parentDataMapping: splitPanelContext.parentDataMapping, + }); + + // onSelectedRowsChange 콜백 호출 + if (onSelectedRowsChange) { + onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); + } + if (onFormDataChange) { + onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); + } + } + } }; // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 @@ -4066,13 +4159,13 @@ export const TableListComponent: React.FC = ({ // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 - const isAttachmentColumn = - inputType === "file" || - inputType === "attachment" || + const isAttachmentColumn = + inputType === "file" || + inputType === "attachment" || column.columnName === "attachments" || column.columnName?.toLowerCase().includes("attachment") || column.columnName?.toLowerCase().includes("file"); - + if (isAttachmentColumn) { // JSONB 배열 또는 JSON 문자열 파싱 let files: any[] = []; @@ -4098,21 +4191,14 @@ export const TableListComponent: React.FC = ({ // 파일 이름 표시 (여러 개면 쉼표로 구분) const { Paperclip } = require("lucide-react"); const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); - + return ( -

- - +
+ + {fileNames} - {files.length > 1 && ( - - ({files.length}) - - )} + {files.length > 1 && ({files.length})}
); } @@ -4677,6 +4763,10 @@ export const TableListComponent: React.FC = ({ fetchTableLabel(); }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); + // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성 + const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right"; + const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null; + useEffect(() => { // console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", { // isDesignMode, @@ -4700,7 +4790,7 @@ export const TableListComponent: React.FC = ({ refreshKey, refreshTrigger, // 강제 새로고침 트리거 isDesignMode, - splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침 + selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침 // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 ]); From 93b37e99e6b0bd4e1e7d3775c2b7803e76c74efa Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 15 Dec 2025 13:46:42 +0900 Subject: [PATCH 04/29] =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=AA=BD=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=EB=90=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/ListWidget.tsx | 14 +++++++++++-- .../dashboard/widgets/ListTestWidget.tsx | 14 +++++++++++-- .../dashboard/widgets/MapTestWidgetV2.tsx | 20 +++++++++---------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index fbf55750..194f7210 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -148,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { switch (format) { case "date": - return new Date(value).toLocaleDateString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "datetime": - return new Date(value).toLocaleString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "number": return Number(value).toLocaleString("ko-KR"); case "currency": diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index bc6b3299..d1303d10 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -180,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { switch (format) { case "date": - return new Date(value).toLocaleDateString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "datetime": - return new Date(value).toLocaleString("ko-KR"); + try { + const dateVal = new Date(value); + return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" }); + } catch { + return String(value); + } case "number": return Number(value).toLocaleString("ko-KR"); case "currency": diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index b3c9e2fb..151c7eff 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -203,14 +203,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setTripInfoLoading(identifier); try { - // user_id 또는 vehicle_number로 조회 (시간은 KST로 변환) + // user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요) const query = `SELECT id, vehicle_number, user_id, - (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, - (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, + last_trip_start, + last_trip_end, last_trip_distance, last_trip_time, - (last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, - (last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, + last_empty_start, + last_empty_end, last_empty_distance, last_empty_time, departure, arrival, status FROM vehicles @@ -281,15 +281,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (identifiers.length === 0) return; try { - // 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환) + // 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요) const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); const query = `SELECT id, vehicle_number, user_id, - (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start, - (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end, + last_trip_start, + last_trip_end, last_trip_distance, last_trip_time, - (last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start, - (last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end, + last_empty_start, + last_empty_end, last_empty_distance, last_empty_time, departure, arrival, status FROM vehicles From 16885225a020d7a53de556a486b9af2260ac8e07 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 15 Dec 2025 14:46:32 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat(edit-modal):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=9B=84=20=EC=A0=9C=EC=96=B4=EB=A1=9C?= =?UTF-8?q?=EC=A7=81(=EB=85=B8=EB=93=9C=20=ED=94=8C=EB=A1=9C=EC=9A=B0)=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8B=A4=ED=96=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditModal에서 INSERT/UPDATE/그룹 저장 완료 후 제어로직 자동 실행 - loadSaveButtonConfig(): 모달 내부 저장 버튼의 제어로직 설정 조회 - findSaveButtonInComponents(): 재귀적으로 저장 버튼 탐색 (conditional-container 내부 포함) - buttonActions.ts: openEditModal 이벤트에 buttonConfig, buttonContext 전달 - executeAfterSaveControl()을 public으로 변경하여 외부 호출 가능 - 제어로직 실행 오류 시 저장 성공 유지, 경고 토스트만 표시 --- frontend/components/screen/EditModal.tsx | 234 +++++++++++++++++- .../button-primary/ButtonPrimaryComponent.tsx | 12 + frontend/lib/utils/buttonActions.ts | 5 +- 3 files changed, 248 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 294bca7f..5a123b3f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -26,12 +26,56 @@ interface EditModalState { onSave?: () => void; groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"]) tableName?: string; // 🆕 테이블명 (그룹 조회용) + buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용) + buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등) + saveButtonConfig?: { + enableDataflowControl?: boolean; + dataflowConfig?: any; + dataflowTiming?: string; + }; // 🆕 모달 내부 저장 버튼의 제어로직 설정 } interface EditModalProps { className?: string; } +/** + * 모달 내부에서 저장 버튼 찾기 (재귀적으로 탐색) + * action.type이 "save"인 button-primary 컴포넌트를 찾음 + */ +const findSaveButtonInComponents = (components: any[]): any | null => { + if (!components || !Array.isArray(components)) return null; + + for (const comp of components) { + // button-primary이고 action.type이 save인 경우 + if ( + comp.componentType === "button-primary" && + comp.componentConfig?.action?.type === "save" + ) { + return comp; + } + + // conditional-container의 sections 내부 탐색 + if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) { + for (const section of comp.componentConfig.sections) { + if (section.screenId) { + // 조건부 컨테이너의 내부 화면은 별도로 로드해야 함 + // 여기서는 null 반환하고, loadSaveButtonConfig에서 처리 + continue; + } + } + } + + // 자식 컴포넌트가 있으면 재귀 탐색 + if (comp.children && Array.isArray(comp.children)) { + const found = findSaveButtonInComponents(comp.children); + if (found) return found; + } + } + + return null; +}; + export const EditModal: React.FC = ({ className }) => { const { user } = useAuth(); const [modalState, setModalState] = useState({ @@ -44,6 +88,9 @@ export const EditModal: React.FC = ({ className }) => { onSave: undefined, groupByColumns: undefined, tableName: undefined, + buttonConfig: undefined, + buttonContext: undefined, + saveButtonConfig: undefined, }); const [screenData, setScreenData] = useState<{ @@ -115,10 +162,88 @@ export const EditModal: React.FC = ({ className }) => { }; }; + // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 + const loadSaveButtonConfig = async (targetScreenId: number): Promise<{ + enableDataflowControl?: boolean; + dataflowConfig?: any; + dataflowTiming?: string; + } | null> => { + try { + // 1. 대상 화면의 레이아웃 조회 + const layoutData = await screenApi.getLayout(targetScreenId); + + if (!layoutData?.components) { + console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId); + return null; + } + + // 2. 저장 버튼 찾기 + let saveButton = findSaveButtonInComponents(layoutData.components); + + // 3. conditional-container가 있는 경우 내부 화면도 탐색 + if (!saveButton) { + for (const comp of layoutData.components) { + if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) { + for (const section of comp.componentConfig.sections) { + if (section.screenId) { + try { + const innerLayoutData = await screenApi.getLayout(section.screenId); + saveButton = findSaveButtonInComponents(innerLayoutData?.components || []); + if (saveButton) { + console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", { + sectionScreenId: section.screenId, + sectionLabel: section.label, + }); + break; + } + } catch (innerError) { + console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId); + } + } + } + if (saveButton) break; + } + } + } + + if (!saveButton) { + console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId); + return null; + } + + // 4. webTypeConfig에서 제어로직 설정 추출 + const webTypeConfig = saveButton.webTypeConfig; + if (webTypeConfig?.enableDataflowControl) { + const config = { + enableDataflowControl: webTypeConfig.enableDataflowControl, + dataflowConfig: webTypeConfig.dataflowConfig, + dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after", + }; + console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config); + return config; + } + + console.log("[EditModal] 저장 버튼에 제어로직 설정 없음"); + return null; + } catch (error) { + console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error); + return null; + } + }; + // 전역 모달 이벤트 리스너 useEffect(() => { - const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; + const handleOpenEditModal = async (event: CustomEvent) => { + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail; + + // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 + let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined; + if (screenId) { + const config = await loadSaveButtonConfig(screenId); + if (config) { + saveButtonConfig = config; + } + } setModalState({ isOpen: true, @@ -130,6 +255,9 @@ export const EditModal: React.FC = ({ className }) => { onSave, groupByColumns, // 🆕 그룹핑 컬럼 tableName, // 🆕 테이블명 + buttonConfig, // 🆕 버튼 설정 + buttonContext, // 🆕 버튼 컨텍스트 + saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정 }); // 편집 데이터로 폼 데이터 초기화 @@ -581,6 +709,46 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", { + hasSaveButtonConfig: !!modalState.saveButtonConfig, + hasButtonConfig: !!modalState.buttonConfig, + controlConfig, + }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + // buttonActions의 executeAfterSaveControl 동적 import + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + // 제어로직 실행 + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData: modalState.editData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } else { + console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + // 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시) + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { toast.info("변경된 내용이 없습니다."); @@ -615,6 +783,37 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { throw new Error(response.message || "생성에 실패했습니다."); @@ -657,6 +856,37 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1942d268..4b88c565 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -376,6 +376,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴) enableDataflowControl: component.webTypeConfig?.enableDataflowControl, dataflowConfig: component.webTypeConfig?.dataflowConfig, + dataflowTiming: component.webTypeConfig?.dataflowTiming, }; } else if (componentConfig.action && typeof componentConfig.action === "object") { // 🔥 이미 객체인 경우에도 제어관리 설정 추가 @@ -383,8 +384,19 @@ export const ButtonPrimaryComponent: React.FC = ({ ...componentConfig.action, enableDataflowControl: component.webTypeConfig?.enableDataflowControl, dataflowConfig: component.webTypeConfig?.dataflowConfig, + dataflowTiming: component.webTypeConfig?.dataflowTiming, }; } + + // 🔍 디버깅: processedConfig.action 확인 + console.log("[ButtonPrimaryComponent] processedConfig.action 생성 완료", { + actionType: processedConfig.action?.type, + enableDataflowControl: processedConfig.action?.enableDataflowControl, + dataflowTiming: processedConfig.action?.dataflowTiming, + dataflowConfig: processedConfig.action?.dataflowConfig, + webTypeConfigRaw: component.webTypeConfig, + componentText: component.text, + }); // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 1ced2836..ede92868 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2086,6 +2086,8 @@ export class ButtonActionExecutor { editData: rowData, groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 tableName: context.tableName, // 🆕 테이블명 전달 + buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용) + buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등) onSave: () => { context.onRefresh?.(); }, @@ -2621,8 +2623,9 @@ export class ButtonActionExecutor { /** * 저장 후 제어 실행 (After Timing) + * EditModal 등 외부에서도 호출 가능하도록 public으로 변경 */ - private static async executeAfterSaveControl( + public static async executeAfterSaveControl( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { From 3c73c202927f7facc41f728564ab2b72e5872425 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 15 Dec 2025 14:51:41 +0900 Subject: [PATCH 06/29] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/services/menuCopyService.ts | 79 ++++++++++++++++--- docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + frontend/hooks/useAutoFill.ts | 1 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 11 files changed, 77 insertions(+), 12 deletions(-) diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index de4eb913..7aa1d825 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -51,3 +51,4 @@ router.get("/data/:groupCode", getAutoFillData); export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index c2f12782..5f57c6ca 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -47,3 +47,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions); export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 71e6c418..b0e3c79a 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -63,3 +63,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions); export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index d92d7d72..0cec35d2 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -51,3 +51,4 @@ router.get("/options/:exclusionCode", getExcludedOptions); export default router; + diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a0e707c1..b12d7a4a 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -332,6 +332,8 @@ export class MenuCopyService { /** * 플로우 수집 + * - 화면 레이아웃에서 참조된 모든 flowId 수집 + * - dataflowConfig.flowConfig.flowId 및 selectedDiagramId 모두 수집 */ private async collectFlows( screenIds: Set, @@ -340,6 +342,7 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); + const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; for (const screenId of screenIds) { const layoutsResult = await client.query( @@ -352,13 +355,35 @@ export class MenuCopyService { // webTypeConfig.dataflowConfig.flowConfig.flowId const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - if (flowId) { - flowIds.add(flowId); + const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { + flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); + } + } + + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); + logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); + } } } } - logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + if (flowIds.size > 0) { + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`); + } else { + logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); + } + return flowIds; } @@ -473,15 +498,21 @@ export class MenuCopyService { } } - // flowId 매핑 (숫자 또는 숫자 문자열) - if (key === "flowId") { + // flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열) + // selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환 + if (key === "flowId" || key === "selectedDiagramId") { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = flowIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 - logger.debug( - ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + logger.info( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } else { + // 매핑이 없으면 경고 로그 + logger.warn( + ` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음` ); } } @@ -742,6 +773,8 @@ export class MenuCopyService { /** * 플로우 복사 + * - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만) + * - 없으면 새로 복사 */ private async copyFlows( flowIds: Set, @@ -757,10 +790,11 @@ export class MenuCopyService { } logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); for (const originalFlowId of flowIds) { try { - // 1) flow_definition 조회 + // 1) 원본 flow_definition 조회 const flowDefResult = await client.query( `SELECT * FROM flow_definition WHERE id = $1`, [originalFlowId] @@ -772,8 +806,29 @@ export class MenuCopyService { } const flowDef = flowDefResult.rows[0]; + logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); - // 2) flow_definition 복사 + // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 + const existingFlowResult = await client.query<{ id: number }>( + `SELECT id FROM flow_definition + WHERE company_code = $1 AND name = $2 AND table_name = $3 + LIMIT 1`, + [targetCompanyCode, flowDef.name, flowDef.table_name] + ); + + let newFlowId: number; + + if (existingFlowResult.rows.length > 0) { + // 기존 플로우가 있으면 재사용 + newFlowId = existingFlowResult.rows[0].id; + flowIdMap.set(originalFlowId, newFlowId); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ); + continue; // 스텝/연결 복사 생략 (기존 것 사용) + } + + // 3) 새 flow_definition 복사 const newFlowResult = await client.query<{ id: number }>( `INSERT INTO flow_definition ( name, description, table_name, is_active, @@ -792,11 +847,11 @@ export class MenuCopyService { ] ); - const newFlowId = newFlowResult.rows[0].id; + newFlowId = newFlowResult.rows[0].id; flowIdMap.set(originalFlowId, newFlowId); logger.info( - ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` ); // 3) flow_step 복사 diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 985d730a..a181ac21 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -583,3 +583,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 285dc6ba..916fbc54 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -356,3 +356,4 @@ - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + diff --git a/frontend/hooks/useAutoFill.ts b/frontend/hooks/useAutoFill.ts index 835a4886..76243569 100644 --- a/frontend/hooks/useAutoFill.ts +++ b/frontend/hooks/useAutoFill.ts @@ -193,3 +193,4 @@ export function applyAutoFillToFormData( } + diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 48bae8dd..baebafe2 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1685,3 +1685,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 179cdd9d..3d4ac8db 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -532,3 +532,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index c5a9a585..5dad3e7d 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -519,3 +519,4 @@ function ScreenViewPage() { + From 93443c98eec483074d701d6474c23d17a539d7be Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 15:40:29 +0900 Subject: [PATCH 07/29] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20RepeaterFieldGroup=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20DB=20w?= =?UTF-8?q?ebType=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=ED=95=91=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../externalDbConnectionPoolService.ts | 4 +- .../components/webtypes/RepeaterInput.tsx | 334 +++++------ .../webtypes/config/RepeaterConfigPanel.tsx | 255 ++++++--- frontend/contexts/ScreenContext.tsx | 93 ++-- .../button-primary/ButtonPrimaryComponent.tsx | 21 +- .../RepeaterFieldGroupRenderer.tsx | 517 ++++++++++++++---- frontend/lib/utils/buttonActions.ts | 165 +++++- frontend/types/repeater.ts | 37 +- 8 files changed, 1034 insertions(+), 392 deletions(-) diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 73077ef1..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } try { - const [rows] = await this.pool.execute(sql, params); - return rows; + const [rows] = await this.pool.execute(sql, params); + return rows; } catch (error: any) { // 연결 닫힘 오류 감지 if ( diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 3116b2c6..0b5a1328 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; -import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater"; +import { + RepeaterFieldGroupConfig, + RepeaterData, + RepeaterItemData, + RepeaterFieldDefinition, + CalculationFormula, +} from "@/types/repeater"; import { cn } from "@/lib/utils"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; @@ -46,7 +52,9 @@ export const RepeaterInput: React.FC = ({ const breakpoint = previewBreakpoint || globalBreakpoint; // 카테고리 매핑 데이터 (값 -> {label, color}) - const [categoryMappings, setCategoryMappings] = useState>>({}); + const [categoryMappings, setCategoryMappings] = useState< + Record> + >({}); // 설정 기본값 const { @@ -78,10 +86,10 @@ export const RepeaterInput: React.FC = ({ // 접힌 상태 관리 (각 항목별) const [collapsedItems, setCollapsedItems] = useState>(new Set()); - + // 🆕 초기 계산 완료 여부 추적 (무한 루프 방지) const initialCalcDoneRef = useRef(false); - + // 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영) const deletedItemIdsRef = useRef([]); @@ -98,47 +106,60 @@ export const RepeaterInput: React.FC = ({ // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트 useEffect(() => { - if (value.length > 0) { - // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) - const calculatedFields = fields.filter(f => f.type === "calculated"); - - if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { - const updatedValue = value.map(item => { - const updatedItem = { ...item }; - let hasChange = false; - - calculatedFields.forEach(calcField => { - const calculatedValue = calculateValue(calcField.formula, updatedItem); - if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { - updatedItem[calcField.name] = calculatedValue; - hasChange = true; - } - }); - - // 🆕 기존 레코드임을 표시 (id가 있는 경우) - if (updatedItem.id) { - updatedItem._existingRecord = true; - } - - return hasChange ? updatedItem : item; - }); - - setItems(updatedValue); - initialCalcDoneRef.current = true; - - // 계산된 값이 있으면 onChange 호출 (초기 1회만) - const dataWithMeta = config.targetTable - ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) - : updatedValue; - onChange?.(dataWithMeta); + // 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음) + if (value.length === 0) { + // minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화 + if (minItems > 0) { + const emptyItems = Array(minItems) + .fill(null) + .map(() => createEmptyItem()); + setItems(emptyItems); } else { - // 🆕 기존 레코드 플래그 추가 - const valueWithFlag = value.map(item => ({ - ...item, - _existingRecord: !!item.id, - })); - setItems(valueWithFlag); + setItems([]); } + initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행 + return; + } + + // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) + const calculatedFields = fields.filter((f) => f.type === "calculated"); + + if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { + const updatedValue = value.map((item) => { + const updatedItem = { ...item }; + let hasChange = false; + + calculatedFields.forEach((calcField) => { + const calculatedValue = calculateValue(calcField.formula, updatedItem); + if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { + updatedItem[calcField.name] = calculatedValue; + hasChange = true; + } + }); + + // 🆕 기존 레코드임을 표시 (id가 있는 경우) + if (updatedItem.id) { + updatedItem._existingRecord = true; + } + + return hasChange ? updatedItem : item; + }); + + setItems(updatedValue); + initialCalcDoneRef.current = true; + + // 계산된 값이 있으면 onChange 호출 (초기 1회만) + const dataWithMeta = config.targetTable + ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) + : updatedValue; + onChange?.(dataWithMeta); + } else { + // 🆕 기존 레코드 플래그 추가 + const valueWithFlag = value.map((item) => ({ + ...item, + _existingRecord: !!item.id, + })); + setItems(valueWithFlag); } }, [value]); @@ -164,14 +185,14 @@ export const RepeaterInput: React.FC = ({ if (items.length <= minItems) { return; } - + // 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요) const removedItem = items[index]; if (removedItem?.id) { console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id); deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id]; } - + const newItems = items.filter((_, i) => i !== index); setItems(newItems); @@ -179,10 +200,10 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용) const currentDeletedIds = deletedItemIdsRef.current; console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds); - + const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + ? newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, // 첫 번째 항목에만 삭제 ID 목록 포함 ...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}), @@ -205,16 +226,16 @@ export const RepeaterInput: React.FC = ({ ...newItems[itemIndex], [fieldName]: value, }; - + // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 - const calculatedFields = fields.filter(f => f.type === "calculated"); - calculatedFields.forEach(calcField => { + const calculatedFields = fields.filter((f) => f.type === "calculated"); + calculatedFields.forEach((calcField) => { const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]); if (calculatedValue !== null) { newItems[itemIndex][calcField.name] = calculatedValue; } }); - + setItems(newItems); console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { itemIndex, @@ -227,8 +248,8 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 유지 const currentDeletedIds = deletedItemIdsRef.current; const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + ? newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, // 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만) ...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}), @@ -288,14 +309,12 @@ export const RepeaterInput: React.FC = ({ */ const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => { if (!formula || !formula.field1) return null; - + const value1 = parseFloat(item[formula.field1]) || 0; - const value2 = formula.field2 - ? (parseFloat(item[formula.field2]) || 0) - : (formula.constantValue ?? 0); - + const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0); + let result: number; - + switch (formula.operator) { case "+": result = value1 + value2; @@ -331,7 +350,7 @@ export const RepeaterInput: React.FC = ({ default: result = value1; } - + return result; }; @@ -341,42 +360,44 @@ export const RepeaterInput: React.FC = ({ * @param format 포맷 설정 * @returns 포맷된 문자열 */ - const formatNumber = ( - value: number | null, - format?: RepeaterFieldDefinition["numberFormat"] - ): string => { + const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => { if (value === null || isNaN(value)) return "-"; - + let formattedValue = value; - + // 소수점 자릿수 적용 if (format?.decimalPlaces !== undefined) { formattedValue = parseFloat(value.toFixed(format.decimalPlaces)); } - + // 천 단위 구분자 - let result = format?.useThousandSeparator !== false - ? formattedValue.toLocaleString("ko-KR", { - minimumFractionDigits: format?.minimumFractionDigits ?? 0, - maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, - }) - : formattedValue.toString(); - + let result = + format?.useThousandSeparator !== false + ? formattedValue.toLocaleString("ko-KR", { + minimumFractionDigits: format?.minimumFractionDigits ?? 0, + maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, + }) + : formattedValue.toString(); + // 접두사/접미사 추가 if (format?.prefix) result = format.prefix + result; if (format?.suffix) result = result + format.suffix; - + return result; }; // 개별 필드 렌더링 const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const isReadonly = disabled || readonly || field.readonly; - + + // 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성 + // "id(를) 입력하세요" 같은 잘못된 기본값 방지 + const defaultPlaceholder = field.placeholder || `${field.label || field.name}`; + const commonProps = { value: value || "", disabled: isReadonly, - placeholder: field.placeholder, + placeholder: defaultPlaceholder, required: field.required, }; @@ -385,25 +406,21 @@ export const RepeaterInput: React.FC = ({ const item = items[itemIndex]; const calculatedValue = calculateValue(field.formula, item); const formattedValue = formatNumber(calculatedValue, field.numberFormat); - - return ( - - {formattedValue} - - ); + + return {formattedValue}; } // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) if (field.type === "category") { if (!value) return -; - + // field.name을 키로 사용 (테이블 리스트와 동일) const mapping = categoryMappings[field.name]; const valueStr = String(value); // 값을 문자열로 변환 const categoryData = mapping?.[valueStr]; const displayLabel = categoryData?.label || valueStr; const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) - + console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { fieldName: field.name, value: valueStr, @@ -412,12 +429,12 @@ export const RepeaterInput: React.FC = ({ displayLabel, displayColor, }); - + // 색상이 "none"이면 일반 텍스트로 표시 if (displayColor === "none") { return {displayLabel}; } - + return ( = ({ if (field.displayMode === "readonly") { // select 타입인 경우 옵션에서 라벨 찾기 if (field.type === "select" && value && field.options) { - const option = field.options.find(opt => opt.value === value); + const option = field.options.find((opt) => opt.value === value); return {option?.label || value}; } - + // 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드) const mapping = categoryMappings[field.name]; if (mapping && value) { @@ -461,16 +478,12 @@ export const RepeaterInput: React.FC = ({ ); } // 색상이 없으면 텍스트로 표시 - return {categoryData.label}; + return {categoryData.label}; } } - + // 일반 텍스트 - return ( - - {value || "-"} - - ); + return {value || "-"}; } switch (field.type) { @@ -500,35 +513,46 @@ export const RepeaterInput: React.FC = ({ {...commonProps} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} rows={3} - className="resize-none min-w-[100px]" + className="min-w-[100px] resize-none" /> ); - case "date": + case "date": { + // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 + let dateValue = value || ""; + if (dateValue && typeof dateValue === "string") { + // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출 + if (dateValue.includes("T")) { + dateValue = dateValue.split("T")[0]; + } + // 유효한 날짜인지 확인 + const parsedDate = new Date(dateValue); + if (isNaN(parsedDate.getTime())) { + dateValue = ""; // 유효하지 않은 날짜면 빈 값 + } + } return ( handleFieldChange(itemIndex, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)} className="min-w-[120px]" /> ); + } case "number": // 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시 if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) { const numValue = parseFloat(value) || 0; const formattedDisplay = formatNumber(numValue, field.numberFormat); - + // 읽기 전용이면 포맷팅된 텍스트만 표시 if (isReadonly) { - return ( - - {formattedDisplay} - - ); + return {formattedDisplay}; } - + // 편집 가능: 입력은 숫자로, 표시는 포맷팅 return (
@@ -540,15 +564,11 @@ export const RepeaterInput: React.FC = ({ max={field.validation?.max} className="pr-1" /> - {value && ( -
- {formattedDisplay} -
- )} + {value &&
{formattedDisplay}
}
); } - + return ( = ({ // 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values useEffect(() => { // 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성) - const categoryFields = fields.filter(f => f.type === "category"); - const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text"); - + const categoryFields = fields.filter((f) => f.type === "category"); + const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text"); + if (categoryFields.length === 0 && readonlyFields.length === 0) return; const loadCategoryMappings = async () => { const apiClient = (await import("@/lib/api/client")).apiClient; - + // 1. 카테고리 타입 필드 매핑 로드 for (const field of categoryFields) { const columnName = field.name; - + if (categoryMappings[columnName]) continue; - + try { const tableName = config.targetTable; if (!tableName) continue; - + console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); - + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -629,10 +649,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -641,29 +661,29 @@ export const RepeaterInput: React.FC = ({ console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error); } } - + // 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드 // material, division 등 조인된 테이블의 카테고리 필드 - const joinedTableFields = ['material', 'division', 'status', 'currency_code']; - const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name)); - + const joinedTableFields = ["material", "division", "status", "currency_code"]; + const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name)); + if (fieldsToLoadFromJoinedTable.length > 0) { // item_info 테이블에서 카테고리 매핑 로드 - const joinedTableName = 'item_info'; - + const joinedTableName = "item_info"; + for (const field of fieldsToLoadFromJoinedTable) { const columnName = field.name; - + if (categoryMappings[columnName]) continue; - + try { console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`); - + const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -671,10 +691,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -694,9 +714,9 @@ export const RepeaterInput: React.FC = ({ if (fields.length === 0) { return (
-
-

필드가 정의되지 않았습니다

-

속성 패널에서 필드를 추가하세요.

+
+

필드가 정의되지 않았습니다

+

속성 패널에서 필드를 추가하세요.

); @@ -706,8 +726,8 @@ export const RepeaterInput: React.FC = ({ if (items.length === 0) { return (
-
-

{emptyMessage}

+
+

{emptyMessage}

{!readonly && !disabled && items.length < maxItems && (
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4d5915e9..e1573998 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -4130,14 +4130,51 @@ export class ButtonActionExecutor { const tripId = this.currentTripId; + // 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용) + let dbDeparture: string | null = null; + let dbArrival: string | null = null; + let dbVehicleId: string | null = null; + + const userId = context.userId || this.trackingUserId; + if (userId) { + try { + const { apiClient } = await import("@/lib/api/client"); + const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles"; + const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id"; + + // DB에서 현재 차량 정보 조회 + const vehicleResponse = await apiClient.post( + `/table-management/tables/${statusTableName}/data`, + { + page: 1, + size: 1, + search: { [keyField]: userId }, + autoFilter: true, + }, + ); + + const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0]; + if (vehicleData) { + dbDeparture = vehicleData.departure || null; + dbArrival = vehicleData.arrival || null; + dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null; + console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId }); + } + } catch (dbError) { + console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError); + } + } + // 마지막 위치 저장 (추적 중이었던 경우에만) if (isTrackingActive) { - const departure = + // DB 값 우선, 없으면 formData 사용 + const departure = dbDeparture || this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; - const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; + const arrival = dbArrival || + this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; - const vehicleId = + const vehicleId = dbVehicleId || this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; await this.saveLocationToHistory( From 8425dece7f73a9a0d2691b374f463d69d0bcf201 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 17:47:16 +0900 Subject: [PATCH 10/29] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=ED=8F=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EA=B0=B1=EC=8B=A0=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen-embedding/EmbeddedScreen.tsx | 82 +++++---- .../button-primary/ButtonPrimaryComponent.tsx | 18 +- .../text-input/TextInputComponent.tsx | 165 ++++++++++-------- 3 files changed, 156 insertions(+), 109 deletions(-) diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index d8e62c00..17cd240f 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -40,32 +40,33 @@ export const EmbeddedScreen = forwardRef(null); const [screenInfo, setScreenInfo] = useState(null); const [formData, setFormData] = useState>(initialFormData || {}); // 🆕 초기 데이터로 시작 + const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용) // 컴포넌트 참조 맵 const componentRefs = useRef>(new Map()); - + // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) const splitPanelContext = useSplitPanelContext(); - + // 🆕 사용자 정보 가져오기 (저장 액션에 필요) const { userId, userName, companyCode } = useAuth(); // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) const contentBounds = React.useMemo(() => { if (layout.length === 0) return { width: 0, height: 0 }; - + let maxRight = 0; let maxBottom = 0; - + layout.forEach((component) => { const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; const right = (compPosition.x || 0) + (size.width || 200); const bottom = (compPosition.y || 0) + (size.height || 40); - + if (right > maxRight) maxRight = right; if (bottom > maxBottom) maxBottom = bottom; }); - + return { width: maxRight, height: maxBottom }; }, [layout]); @@ -92,26 +93,49 @@ export const EmbeddedScreen = forwardRef { // 우측 화면인 경우에만 적용 - if (position !== "right" || !splitPanelContext) return; - - // 자동 데이터 전달이 비활성화된 경우 스킵 - if (splitPanelContext.disableAutoDataTransfer) { - console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달"); + if (position !== "right" || !splitPanelContext) { return; } - - const mappedData = splitPanelContext.getMappedParentData(); - if (Object.keys(mappedData).length > 0) { - console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); - setFormData((prev) => ({ - ...prev, - ...mappedData, - })); + + // 자동 데이터 전달이 비활성화된 경우 스킵 + if (splitPanelContext.disableAutoDataTransfer) { + return; } - }, [position, splitPanelContext, splitPanelContext?.selectedLeftData]); + + // 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집 + const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string); + + // 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기 + const initializedFormData: Record = {}; + + // 먼저 모든 컬럼을 빈 문자열로 초기화 + allColumnNames.forEach((colName) => { + initializedFormData[colName] = ""; + }); + + // selectedLeftData가 있으면 해당 값으로 덮어쓰기 + if (selectedLeftData && Object.keys(selectedLeftData).length > 0) { + Object.keys(selectedLeftData).forEach((key) => { + // null/undefined는 빈 문자열로, 나머지는 그대로 + initializedFormData[key] = selectedLeftData[key] ?? ""; + }); + } + + console.log("🔗 [EmbeddedScreen] 우측 폼 데이터 교체:", { + allColumnNames, + selectedLeftDataKeys: selectedLeftData ? Object.keys(selectedLeftData) : [], + initializedFormDataKeys: Object.keys(initializedFormData), + }); + + setFormData(initializedFormData); + setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링 + }, [position, splitPanelContext, selectedLeftData, layout]); // 선택 변경 이벤트 전파 useEffect(() => { @@ -377,15 +401,15 @@ export const EmbeddedScreen = forwardRef화면에 컴포넌트가 없습니다.

) : ( -
{layout.map((component) => { const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; - + // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 // 부모 컨테이너의 100%를 기준으로 계산 const componentStyle: React.CSSProperties = { @@ -397,13 +421,9 @@ export const EmbeddedScreen = forwardRef +
= ({ } } - // 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합 - // screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터 - // props.formData: 부모에서 전달된 폼 데이터 + // 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합 + // 우선순위: props.formData > screenContext.formData > splitPanelParentData const screenContextFormData = screenContext?.formData || {}; const propsFormData = formData || {}; - // 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드 - // (RepeaterFieldGroup 데이터는 screenContext에만 있음) - const effectiveFormData = { ...propsFormData, ...screenContextFormData }; + // 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드 + // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) + let effectiveFormData = { ...propsFormData, ...screenContextFormData }; + + // 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 + if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { + effectiveFormData = { ...splitPanelParentData }; + console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData)); + } console.log("🔍 [ButtonPrimary] formData 선택:", { hasScreenContextFormData: Object.keys(screenContextFormData).length > 0, screenContextKeys: Object.keys(screenContextFormData), hasPropsFormData: Object.keys(propsFormData).length > 0, propsFormDataKeys: Object.keys(propsFormData), + hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0, splitPanelPosition, effectiveFormDataKeys: Object.keys(effectiveFormData), }); diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index ad37f19f..8ffa8afe 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -53,7 +53,7 @@ export const TextInputComponent: React.FC = ({ // 자동생성된 값 상태 const [autoGeneratedValue, setAutoGeneratedValue] = useState(""); - + // API 호출 중복 방지를 위한 ref const isGeneratingRef = React.useRef(false); const hasGeneratedRef = React.useRef(false); @@ -104,7 +104,6 @@ export const TextInputComponent: React.FC = ({ const currentFormValue = formData?.[component.columnName]; const currentComponentValue = component.value; - // 자동생성된 값이 없고, 현재 값도 없을 때만 생성 if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { isGeneratingRef.current = true; // 생성 시작 플래그 @@ -145,7 +144,7 @@ export const TextInputComponent: React.FC = ({ if (isInteractive && onFormDataChange && component.columnName) { console.log("📝 formData 업데이트:", component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue); - + // 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함) if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { const ruleIdKey = `${component.columnName}_numberingRuleId`; @@ -181,12 +180,12 @@ export const TextInputComponent: React.FC = ({ // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", // 숨김 기능: 편집 모드에서만 연하게 표시 - ...(isHidden && - isDesignMode && { - opacity: 0.4, - backgroundColor: "hsl(var(--muted))", - pointerEvents: "auto", - }), + ...(isHidden && + isDesignMode && { + opacity: 0.4, + backgroundColor: "hsl(var(--muted))", + pointerEvents: "auto", + }), }; // 디자인 모드 스타일 @@ -361,7 +360,7 @@ export const TextInputComponent: React.FC = ({
{/* 라벨 렌더링 */} {component.label && component.style?.labelDisplay !== false && ( -
diff --git a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx index beff4783..52889865 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx @@ -306,6 +306,19 @@ export const CardDisplayConfigPanel: React.FC = ({ 편집 버튼
+ +
+ handleNestedChange("cardStyle.showDeleteButton", e.target.checked)} + className="rounded border-gray-300" + /> + +
)}
diff --git a/frontend/lib/registry/components/card-display/types.ts b/frontend/lib/registry/components/card-display/types.ts index 7154eb72..d4174453 100644 --- a/frontend/lib/registry/components/card-display/types.ts +++ b/frontend/lib/registry/components/card-display/types.ts @@ -16,6 +16,7 @@ export interface CardStyleConfig { showActions?: boolean; // 액션 버튼 표시 여부 (전체) showViewButton?: boolean; // 상세보기 버튼 표시 여부 showEditButton?: boolean; // 편집 버튼 표시 여부 + showDeleteButton?: boolean; // 삭제 버튼 표시 여부 } /** From f6051e8bbdc0f01024d960a8241ec3c743de50c8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 15 Dec 2025 18:39:59 +0900 Subject: [PATCH 13/29] =?UTF-8?q?fix(button-actions):=20openModalWithData?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=EC=97=90=EC=84=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EB=90=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleOpenModalWithData에서 modalDataStore 데이터를 selectedData/selectedIds로 이벤트에 포함 - RepeatScreenModal에서 groupedData로 사용할 수 있도록 데이터 전달 경로 완성 - ButtonConfigPanel 필드 매핑 UI를 세로 배치로 변경하여 가독성 개선 - split-panel-layout2 컴포넌트 타입 소스 테이블 감지 지원 추가 - currentTableName 폴백 로직 추가로 테이블명 감지 안정성 향상 --- .../config-panels/ButtonConfigPanel.tsx | 168 +++++++++++++----- frontend/lib/utils/buttonActions.ts | 10 ++ 2 files changed, 129 insertions(+), 49 deletions(-) diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 39f32a73..54315683 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -333,22 +333,72 @@ export const ButtonConfigPanel: React.FC = ({ const loadModalMappingColumns = async () => { // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 - // allComponents에서 split-panel-layout 또는 table-list 찾기 let sourceTableName: string | null = null; + console.log("[openModalWithData] 컬럼 로드 시작:", { + allComponentsCount: allComponents.length, + currentTableName, + targetScreenId: config.action?.targetScreenId, + }); + + // 모든 컴포넌트 타입 로그 + allComponents.forEach((comp, idx) => { + const compType = comp.componentType || (comp as any).componentConfig?.type; + console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`); + }); + for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; + const compConfig = (comp as any).componentConfig || {}; + + // 분할 패널 타입들 (다양한 경로에서 테이블명 추출) if (compType === "split-panel-layout" || compType === "screen-split-panel") { - // 분할 패널의 좌측 테이블명 - sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName || - (comp as any).componentConfig?.leftTableName; - break; + sourceTableName = compConfig?.leftPanel?.tableName || + compConfig?.leftTableName || + compConfig?.tableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`); + break; + } } + + // split-panel-layout2 타입 (새로운 분할 패널) + if (compType === "split-panel-layout2") { + sourceTableName = compConfig?.leftPanel?.tableName || + compConfig?.tableName || + compConfig?.leftTableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`); + break; + } + } + + // 테이블 리스트 타입 if (compType === "table-list") { - sourceTableName = (comp as any).componentConfig?.tableName; + sourceTableName = compConfig?.tableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`); + break; + } + } + + // 🆕 모든 컴포넌트에서 tableName 찾기 (폴백) + if (!sourceTableName && compConfig?.tableName) { + sourceTableName = compConfig.tableName; + console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`); break; } } + + // 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명) + if (!sourceTableName && currentTableName) { + sourceTableName = currentTableName; + console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`); + } + + if (!sourceTableName) { + console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다."); + } // 소스 테이블 컬럼 로드 if (sourceTableName) { @@ -361,11 +411,11 @@ export const ButtonConfigPanel: React.FC = ({ if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + name: col.name || col.columnName || col.column_name, + label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalSourceColumns(columns); - console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length); + console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length); } } } catch (error) { @@ -379,8 +429,12 @@ export const ButtonConfigPanel: React.FC = ({ try { // 타겟 화면 정보 가져오기 const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); + console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data); + if (screenResponse.data.success && screenResponse.data.data) { const targetTableName = screenResponse.data.data.tableName; + console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName); + if (targetTableName) { const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (columnResponse.data.success) { @@ -390,23 +444,27 @@ export const ButtonConfigPanel: React.FC = ({ if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + name: col.name || col.columnName || col.column_name, + label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalTargetColumns(columns); - console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length); + console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length); } } + } else { + console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다."); } } } catch (error) { console.error("타겟 화면 테이블 컬럼 로드 실패:", error); } + } else { + console.warn("[openModalWithData] 타겟 화면 ID가 없습니다."); } }; loadModalMappingColumns(); - }, [config.action?.type, config.action?.targetScreenId, allComponents]); + }, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]); // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { @@ -1158,11 +1216,12 @@ export const ButtonConfigPanel: React.FC = ({

) : ( -
+
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
+
+ {/* 소스 필드 선택 (Combobox) - 세로 배치 */} +
+ setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} @@ -1171,15 +1230,17 @@ export const ButtonConfigPanel: React.FC = ({ - + = ({ value={modalSourceSearch[index] || ""} onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))} /> - + 컬럼을 찾을 수 없습니다 {modalSourceColumns.map((col) => ( @@ -1208,9 +1269,9 @@ export const ButtonConfigPanel: React.FC = ({ mapping.sourceField === col.name ? "opacity-100" : "opacity-0" )} /> - {col.label} + {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -1221,10 +1282,14 @@ export const ButtonConfigPanel: React.FC = ({
- + {/* 화살표 표시 */} +
+ +
- {/* 타겟 필드 선택 (Combobox) */} -
+ {/* 타겟 필드 선택 (Combobox) - 세로 배치 */} +
+ setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} @@ -1233,15 +1298,17 @@ export const ButtonConfigPanel: React.FC = ({ - + = ({ value={modalTargetSearch[index] || ""} onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))} /> - + 컬럼을 찾을 수 없습니다 {modalTargetColumns.map((col) => ( @@ -1270,9 +1337,9 @@ export const ButtonConfigPanel: React.FC = ({ mapping.targetField === col.name ? "opacity-100" : "opacity-0" )} /> - {col.label} + {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -1284,19 +1351,22 @@ export const ButtonConfigPanel: React.FC = ({
{/* 삭제 버튼 */} - +
+ +
))}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 587513e4..394e15c2 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2020,11 +2020,18 @@ export class ButtonActionExecutor { }); } + // 🆕 modalDataStore에서 선택된 전체 데이터 가져오기 (RepeatScreenModal에서 사용) + const modalData = dataRegistry[dataSourceId] || []; + const selectedData = modalData.map((item: any) => item.originalData || item); + const selectedIds = selectedData.map((row: any) => row.id).filter(Boolean); + console.log("📦 [openModalWithData] 부모 데이터 전달:", { dataSourceId, rawParentData, mappedParentData: parentData, fieldMappings: config.fieldMappings, + selectedDataCount: selectedData.length, + selectedIds, }); // 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함) @@ -2036,6 +2043,9 @@ export class ButtonActionExecutor { size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음) splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용) + // 🆕 선택된 데이터 전달 (RepeatScreenModal에서 groupedData로 사용) + selectedData: selectedData, + selectedIds: selectedIds, }, }); From 4cff9e4cecd2720b2caed841deeba42f0df4e2d8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 16 Dec 2025 09:13:42 +0900 Subject: [PATCH 14/29] =?UTF-8?q?fix(button-actions):=20=EC=B6=9C=ED=95=98?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EB=AA=A8=EB=8B=AC=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=84=EB=8B=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleModal: context.selectedRowsData를 selectedData로 복원하여 출하계획 등 모달에서 사용 가능 - handleOpenModalWithData: modalDataStore 데이터를 selectedData/selectedIds로 이벤트에 포함 - ButtonConfigPanel: split-panel-layout2 타입 소스 테이블 감지 지원 추가 - ButtonConfigPanel: column_name/display_name 컬럼 형식 폴백 추가 - ButtonConfigPanel: currentTableName 폴백으로 테이블명 감지 안정성 향상 - ButtonConfigPanel: 필드 매핑 UI를 세로 배치로 변경하여 가독성 개선 --- .../button-primary/ButtonPrimaryComponent.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 26bbd0c9..160591c6 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -880,6 +880,44 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + // 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단 + // (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지) + if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) { + toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요."); + return; + } + + // 수정(edit) 액션 검증 + if (processedConfig.action.type === "edit") { + // 선택된 데이터가 없으면 경고 + if (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) { + toast.warning("수정할 항목을 선택해주세요."); + return; + } + + // groupByColumns 설정이 있으면 해당 컬럼 값이 유일한지 확인 + const groupByColumns = processedConfig.action.groupByColumns; + if (groupByColumns && groupByColumns.length > 0 && effectiveSelectedRowsData.length > 1) { + // 첫 번째 그룹핑 컬럼 기준으로 중복 체크 (예: order_no) + const groupByColumn = groupByColumns[0]; + const uniqueValues = new Set( + effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean) + ); + + if (uniqueValues.size > 1) { + // 컬럼명을 한글로 변환 (order_no -> 수주번호) + const columnLabels: Record = { + order_no: "수주번호", + shipment_no: "출하번호", + purchase_no: "구매번호", + }; + const columnLabel = columnLabels[groupByColumn] || groupByColumn; + toast.warning(`${columnLabel} 하나만 선택해주세요. (현재 ${uniqueValues.size}개 선택됨)`); + return; + } + } + } + // 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등) const componentConfigs: Record = {}; if (allComponents && Array.isArray(allComponents)) { From a2582a28e467ac66a364b0aa1cc92bb45962a5b3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 16 Dec 2025 10:02:16 +0900 Subject: [PATCH 15/29] =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EC=8B=9C=20=ED=95=98=EB=A3=A8=20=EB=B0=80=EB=A6=AC=EB=8A=94?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EC=A1=B4=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/webtypes/RepeaterInput.tsx | 32 ++++++---- .../date-input/DateInputComponent.tsx | 61 +++++++++++-------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 0b5a1328..1595036b 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -182,7 +182,8 @@ export const RepeaterInput: React.FC = ({ // 항목 제거 const handleRemoveItem = (index: number) => { - if (items.length <= minItems) { + // 🆕 minItems가 0이면 모든 항목 삭제 가능, 그 외에는 minItems 이하로 줄일 수 없음 + if (minItems > 0 && items.length <= minItems) { return; } @@ -518,17 +519,26 @@ export const RepeaterInput: React.FC = ({ ); case "date": { - // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 + // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결) let dateValue = value || ""; if (dateValue && typeof dateValue === "string") { - // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출 + // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출 if (dateValue.includes("T")) { - dateValue = dateValue.split("T")[0]; - } - // 유효한 날짜인지 확인 - const parsedDate = new Date(dateValue); - if (isNaN(parsedDate.getTime())) { - dateValue = ""; // 유효하지 않은 날짜면 빈 값 + const date = new Date(dateValue); + if (!isNaN(date.getTime())) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + dateValue = `${year}-${month}-${day}`; + } else { + dateValue = ""; + } + } else { + // 유효한 날짜인지 확인 + const parsedDate = new Date(dateValue); + if (isNaN(parsedDate.getTime())) { + dateValue = ""; // 유효하지 않은 날짜면 빈 값 + } } } return ( @@ -801,7 +811,7 @@ export const RepeaterInput: React.FC = ({ {/* 삭제 버튼 */} - {!readonly && !disabled && items.length > minItems && ( + {!readonly && !disabled && (minItems === 0 || items.length > minItems) && ( + )} +
-
{/* Repeater 테이블 */} @@ -826,6 +853,8 @@ export function ModalRepeaterTableComponent({ onRowDelete={handleRowDelete} activeDataSources={activeDataSources} onDataSourceChange={handleDataSourceChange} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 56e9a321..1badecf9 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -3,9 +3,9 @@ import React, { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Trash2, ChevronDown, Check } from "lucide-react"; +import { ChevronDown, Check } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { RepeaterColumnConfig } from "./types"; import { cn } from "@/lib/utils"; @@ -18,6 +18,9 @@ interface RepeaterTableProps { // 동적 데이터 소스 관련 activeDataSources?: Record; // 컬럼별 현재 활성화된 데이터 소스 ID onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백 + // 체크박스 선택 관련 + selectedRows: Set; // 선택된 행 인덱스 + onSelectionChange: (selectedRows: Set) => void; // 선택 변경 콜백 } export function RepeaterTable({ @@ -28,6 +31,8 @@ export function RepeaterTable({ onRowDelete, activeDataSources = {}, onDataSourceChange, + selectedRows, + onSelectionChange, }: RepeaterTableProps) { const [editingCell, setEditingCell] = useState<{ rowIndex: number; @@ -112,6 +117,33 @@ export function RepeaterTable({ onRowChange(rowIndex, newRow); }; + // 전체 선택 체크박스 핸들러 + const handleSelectAll = (checked: boolean) => { + if (checked) { + // 모든 행 선택 + const allIndices = new Set(data.map((_, index) => index)); + onSelectionChange(allIndices); + } else { + // 전체 해제 + onSelectionChange(new Set()); + } + }; + + // 개별 행 선택 핸들러 + const handleRowSelect = (rowIndex: number, checked: boolean) => { + const newSelection = new Set(selectedRows); + if (checked) { + newSelection.add(rowIndex); + } else { + newSelection.delete(rowIndex); + } + onSelectionChange(newSelection); + }; + + // 전체 선택 상태 계산 + const isAllSelected = data.length > 0 && selectedRows.size === data.length; + const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length; + const renderCell = ( row: any, column: RepeaterColumnConfig, @@ -215,8 +247,17 @@ export function RepeaterTable({ - {columns.map((col) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; @@ -303,16 +344,13 @@ export function RepeaterTable({ ); })} - {data.length === 0 ? ( ) : ( data.map((row, rowIndex) => ( - - + {columns.map((col) => ( ))} - )) )} diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index 0c2edc4e..2a77f96a 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -1744,11 +1744,13 @@ function RowNumberingConfigSection({ - {tableColumns.map((col, index) => ( - - {col.label || col.field} - - ))} + {tableColumns + .filter((col) => col.field && col.field.trim() !== "") + .map((col, index) => ( + + {col.label || col.field} + + ))}

From d8329d31e481496b12bed07f11eabcc1c6ff1d48 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 11:49:10 +0900 Subject: [PATCH 19/29] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen-embedding/EmbeddedScreen.tsx | 17 +- .../screen-embedding/ScreenSplitPanel.tsx | 17 -- .../components/screen/widgets/TabsWidget.tsx | 44 +--- frontend/contexts/SplitPanelContext.tsx | 10 - frontend/contexts/TableOptionsContext.tsx | 6 - .../lib/registry/DynamicComponentRenderer.tsx | 50 ----- .../button-primary/ButtonPrimaryComponent.tsx | 52 +---- .../card-display/CardDisplayComponent.tsx | 201 +++++++++++------- .../ScreenSplitPanelRenderer.tsx | 21 -- .../table-list/TableListComponent.tsx | 59 ----- .../table-search-widget/TableSearchWidget.tsx | 23 -- .../components/tabs/tabs-component.tsx | 6 - 12 files changed, 128 insertions(+), 378 deletions(-) diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index b0d39d22..12496310 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -76,7 +76,6 @@ export const EmbeddedScreen = forwardRef { - console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value }); setFormData((prev) => ({ ...prev, [fieldName]: value, @@ -88,10 +87,9 @@ export const EmbeddedScreen = forwardRef { if (initialFormData && Object.keys(initialFormData).length > 0) { - console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData); setFormData(initialFormData); } }, [initialFormData]); @@ -135,12 +133,6 @@ export const EmbeddedScreen = forwardRef v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링 }, [position, splitPanelContext, selectedLeftData, layout]); @@ -160,13 +152,6 @@ export const EmbeddedScreen = forwardRef { - console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio }); setSplitRatio(configSplitRatio); }, [configSplitRatio]); diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 200e2db3..7990a2a6 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -25,12 +25,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge persistSelection = false, } = component; - console.log("🎨 TabsWidget 렌더링:", { - componentId: component.id, - tabs, - tabsLength: tabs.length, - component, - }); const storageKey = `tabs-${component.id}-selected`; @@ -67,15 +61,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 초기 로드 시 선택된 탭의 화면 불러오기 useEffect(() => { const currentTab = visibleTabs.find((t) => t.id === selectedTab); - console.log("🔄 초기 탭 로드:", { - selectedTab, - currentTab, - hasScreenId: !!currentTab?.screenId, - screenId: currentTab?.screenId, - }); - if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) { - console.log("📥 초기 화면 로딩 시작:", currentTab.screenId); loadScreenLayout(currentTab.screenId); } }, [selectedTab, visibleTabs]); @@ -83,26 +69,20 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 화면 레이아웃 로드 const loadScreenLayout = async (screenId: number) => { if (screenLayouts[screenId]) { - console.log("✅ 이미 로드된 화면:", screenId); return; // 이미 로드됨 } - console.log("📥 화면 레이아웃 로딩 시작:", screenId); setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); - console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data }); if (response.data.success && response.data.data) { - console.log("✅ 화면 레이아웃 로드 완료:", screenId); setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data })); - } else { - console.error("❌ 화면 레이아웃 로드 실패 - success false"); } } catch (error) { - console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error); + console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error); } finally { setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } @@ -110,10 +90,9 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 탭 변경 핸들러 const handleTabChange = (tabId: string) => { - console.log("🔄 탭 변경:", tabId); setSelectedTab(tabId); - // 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) + // 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) setMountedTabs(prev => { if (prev.has(tabId)) return prev; const newSet = new Set(prev); @@ -123,10 +102,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 해당 탭의 화면 로드 const tab = visibleTabs.find((t) => t.id === tabId); - console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId }); - if (tab && tab.screenId && !screenLayouts[tab.screenId]) { - console.log("📥 탭 변경 시 화면 로딩:", tab.screenId); loadScreenLayout(tab.screenId); } }; @@ -157,7 +133,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge }; if (visibleTabs.length === 0) { - console.log("⚠️ 보이는 탭이 없음"); return (

탭이 없습니다

@@ -165,13 +140,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge ); } - console.log("🎨 TabsWidget 최종 렌더링:", { - visibleTabsCount: visibleTabs.length, - selectedTab, - screenLayoutsKeys: Object.keys(screenLayouts), - loadingScreensKeys: Object.keys(loadingScreens), - }); - return (
| null) => { - logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, { - hasData: !!data, - dataKeys: data ? Object.keys(data) : [], - }); setSelectedLeftData(data); }, []); @@ -323,11 +319,6 @@ export function SplitPanelProvider({ } } - logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, { - autoMappedKeys: Object.keys(selectedLeftData), - explicitMappings: parentDataMapping.length, - finalKeys: Object.keys(mappedData), - }); return mappedData; }, [selectedLeftData, parentDataMapping]); @@ -350,7 +341,6 @@ export function SplitPanelProvider({ } } - logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues); return filterValues; }, [selectedLeftData, linkedFilters]); diff --git a/frontend/contexts/TableOptionsContext.tsx b/frontend/contexts/TableOptionsContext.tsx index 5f03a8e1..d706443f 100644 --- a/frontend/contexts/TableOptionsContext.tsx +++ b/frontend/contexts/TableOptionsContext.tsx @@ -83,14 +83,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ const updatedTable = { ...table, dataCount: count }; const newMap = new Map(prev); newMap.set(tableId, updatedTable); - console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", { - tableId, - count, - updated: true, - }); return newMap; } - console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId); return prev; }); }, []); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index dc92c38a..74f15d2f 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -226,43 +226,6 @@ export const DynamicComponentRenderer: React.FC = // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); - // 🔍 디버깅: screen-split-panel 조회 결과 확인 - if (componentType === "screen-split-panel") { - console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", { - componentType, - found: !!newComponent, - componentId: component.id, - componentConfig: component.componentConfig, - hasFormData: !!props.formData, - formDataKeys: props.formData ? Object.keys(props.formData) : [], - registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), - }); - } - - // 🔍 디버깅: select-basic 조회 결과 확인 - if (componentType === "select-basic") { - console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { - componentType, - found: !!newComponent, - componentId: component.id, - componentConfig: component.componentConfig, - }); - } - - // 🔍 디버깅: text-input 컴포넌트 조회 결과 확인 - if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") { - console.log("🔍 [DynamicComponentRenderer] text-input 조회:", { - componentType, - componentId: component.id, - componentLabel: component.label, - componentConfig: component.componentConfig, - webTypeConfig: (component as any).webTypeConfig, - autoGeneration: (component as any).autoGeneration, - found: !!newComponent, - registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), - }); - } - if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { @@ -324,19 +287,6 @@ export const DynamicComponentRenderer: React.FC = currentValue = formData?.[fieldName] || ""; } - // 🆕 디버깅: text-input 값 추출 확인 - if (componentType === "text-input" && formData && Object.keys(formData).length > 0) { - console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", { - componentId: component.id, - componentLabel: component.label, - columnName: (component as any).columnName, - fieldName, - currentValue, - hasFormData: !!formData, - formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만 - }); - } - // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 160591c6..4a7ad7e9 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -388,16 +388,6 @@ export const ButtonPrimaryComponent: React.FC = ({ }; } - // 🔍 디버깅: processedConfig.action 확인 - console.log("[ButtonPrimaryComponent] processedConfig.action 생성 완료", { - actionType: processedConfig.action?.type, - enableDataflowControl: processedConfig.action?.enableDataflowControl, - dataflowTiming: processedConfig.action?.dataflowTiming, - dataflowConfig: processedConfig.action?.dataflowConfig, - webTypeConfigRaw: component.webTypeConfig, - componentText: component.text, - }); - // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) @@ -839,10 +829,6 @@ export const ButtonPrimaryComponent: React.FC = ({ groupedData.length > 0 ) { effectiveSelectedRowsData = groupedData; - console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", { - count: groupedData.length, - data: groupedData, - }); } // modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) @@ -858,12 +844,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성) return item.originalData || item; }); - console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { - tableName: effectiveTableName, - count: modalData.length, - rawData: modalData, - extractedData: effectiveSelectedRowsData, - }); } } catch (error) { console.warn("modalDataStore 접근 실패:", error); @@ -928,17 +908,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 🆕 디버깅: tableName 확인 - console.log("🔍 [ButtonPrimaryComponent] context 생성:", { - propsTableName: tableName, - contextTableName: screenContext?.tableName, - effectiveTableName, - propsScreenId: screenId, - contextScreenId: screenContext?.screenId, - effectiveScreenId, - }); - - // 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) + // 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) // 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴 // (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록) let splitPanelParentData: Record | undefined; @@ -947,13 +917,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨) if (splitPanelPosition !== "left") { splitPanelParentData = splitPanelContext.getMappedParentData(); - if (Object.keys(splitPanelParentData).length > 0) { - console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", { - splitPanelParentData, - splitPanelPosition, - isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안 - }); - } } } @@ -966,22 +929,11 @@ export const ButtonPrimaryComponent: React.FC = ({ // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) let effectiveFormData = { ...propsFormData, ...screenContextFormData }; - // 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 + // 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { effectiveFormData = { ...splitPanelParentData }; - console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData)); } - console.log("🔍 [ButtonPrimary] formData 선택:", { - hasScreenContextFormData: Object.keys(screenContextFormData).length > 0, - screenContextKeys: Object.keys(screenContextFormData), - hasPropsFormData: Object.keys(propsFormData).length > 0, - propsFormDataKeys: Object.keys(propsFormData), - hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0, - splitPanelPosition, - effectiveFormDataKeys: Object.keys(effectiveFormData), - }); - const context: ButtonActionContext = { formData: effectiveFormData, originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 620715fd..db45531b 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -61,20 +61,17 @@ export const CardDisplayComponent: React.FC = ({ // 테이블 데이터 상태 관리 const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정 + const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부 + const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력 // 필터 상태 (검색 필터 위젯에서 전달받은 필터) const [filters, setFiltersInternal] = useState([]); - // 필터 상태 변경 래퍼 (로깅용) + // 필터 상태 변경 래퍼 const setFilters = useCallback((newFilters: TableFilter[]) => { - console.log("🎴 [CardDisplay] setFilters 호출됨:", { - componentId: component.id, - filtersCount: newFilters.length, - filters: newFilters, - }); setFiltersInternal(newFilters); - }, [component.id]); + }, []); // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) const [columnMeta, setColumnMeta] = useState< @@ -125,10 +122,6 @@ export const CardDisplayComponent: React.FC = ({ // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) const deleteData = [data]; - console.log("🗑️ [CardDisplay] 삭제 요청:", { - tableName: tableNameToUse, - data: deleteData, - }); // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 @@ -143,7 +136,6 @@ export const CardDisplayComponent: React.FC = ({ }); if (response.data.success) { - console.log("삭제 완료:", response.data.data?.deletedCount || 1, "건"); alert("삭제되었습니다."); // 로컬 상태에서 삭제된 항목 제거 @@ -157,11 +149,9 @@ export const CardDisplayComponent: React.FC = ({ setSelectedRows(newSelectedRows); } } else { - console.error("삭제 실패:", response.data.error); alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); } } catch (error: any) { - console.error("삭제 중 오류 발생:", error); const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); } @@ -194,8 +184,7 @@ export const CardDisplayComponent: React.FC = ({ // loadTableData(); } catch (error) { - console.error("❌ 편집 저장 실패:", error); - alert("❌ 저장에 실패했습니다."); + alert("저장에 실패했습니다."); } }; @@ -204,6 +193,25 @@ export const CardDisplayComponent: React.FC = ({ const loadTableData = async () => { // 디자인 모드에서는 테이블 데이터를 로드하지 않음 if (isDesignMode) { + setLoading(false); + setInitialLoadDone(true); + return; + } + + // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) + // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 + const isRightPanelEarly = splitPanelPosition === "right"; + const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + if (isRightPanelEarly && !hasSelectedLeftDataEarly) { + // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) + // 초기 로드가 아닌 경우에는 데이터를 지우지 않음 + if (!initialLoadDone) { + setLoadedTableData([]); + } + setLoading(false); + setInitialLoadDone(true); return; } @@ -211,6 +219,8 @@ export const CardDisplayComponent: React.FC = ({ const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 if (!tableNameToUse) { + setLoading(false); + setInitialLoadDone(true); return; } @@ -251,19 +261,23 @@ export const CardDisplayComponent: React.FC = ({ } linkedFilterValues = tableSpecificFilters; - console.log("🎴 [CardDisplay] 연결 필터 확인:", { - tableNameToUse, - hasLinkedFiltersConfigured, - hasSelectedLeftData, - linkedFilterValues, - }); } - // 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 - if (splitPanelContext && hasLinkedFiltersConfigured && !hasSelectedLeftData) { - console.log("🎴 [CardDisplay] 연결 필터 활성화됨 - 좌측 선택 대기"); + // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 + // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 + // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 + const isRightPanelFromContext = splitPanelPosition === "right"; + const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId + ? splitPanelContext.getPositionByScreenId(screenId as number) === "right" + : false; + const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; + const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + + + if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { setLoadedTableData([]); setLoading(false); + setInitialLoadDone(true); return; } @@ -277,7 +291,6 @@ export const CardDisplayComponent: React.FC = ({ search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, }; - console.log("🎴 [CardDisplay] API 호출 파라미터:", apiParams); // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([ @@ -298,7 +311,6 @@ export const CardDisplayComponent: React.FC = ({ codeCategory: item.codeCategory || item.code_category, }; }); - console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta); setColumnMeta(meta); // 카테고리 타입 컬럼 찾기 및 매핑 로드 @@ -306,17 +318,14 @@ export const CardDisplayComponent: React.FC = ({ .filter(([_, m]) => m.inputType === "category") .map(([columnName]) => columnName); - console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns); if (categoryColumns.length > 0) { const mappings: Record> = {}; for (const columnName of categoryColumns) { try { - console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`); const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); - console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data); if (response.data.success && response.data.data) { const mapping: Record = {}; @@ -328,29 +337,27 @@ export const CardDisplayComponent: React.FC = ({ const rawColor = item.color ?? item.badge_color; const color = (rawColor && rawColor !== "none") ? rawColor : undefined; mapping[code] = { label, color }; - console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`); }); mappings[columnName] = mapping; } } catch (error) { - console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error); + // 카테고리 매핑 로드 실패 시 무시 } } - console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings); setCategoryMappings(mappings); } } catch (error) { - console.error(`❌ CardDisplay: 데이터 로딩 실패`, error); setLoadedTableData([]); setLoadedTableColumns([]); } finally { setLoading(false); + setInitialLoadDone(true); } }; loadTableData(); - }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData]); + }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition]); // 컴포넌트 설정 (기본값 보장) const componentConfig = { @@ -390,8 +397,34 @@ export const CardDisplayComponent: React.FC = ({ componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))"; } + // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) + const isRightPanelForDisplay = splitPanelPosition === "right" || + (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); + const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; + const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay && + Object.keys(selectedLeftDataForDisplay).length > 0; + + // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 + useEffect(() => { + if (hasSelectedLeftDataForDisplay) { + setHasEverSelectedLeftData(true); + } + }, [hasSelectedLeftDataForDisplay]); + + // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 + // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 + const shouldHideDataForRightPanel = isRightPanelForDisplay && + !hasEverSelectedLeftData && + !hasSelectedLeftDataForDisplay; + // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) const displayData = useMemo(() => { + // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환 + if (shouldHideDataForRightPanel) { + return []; + } + // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) if (loadedTableData.length > 0) { return loadedTableData; @@ -408,7 +441,7 @@ export const CardDisplayComponent: React.FC = ({ // 데이터가 없으면 빈 배열 반환 return []; - }, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]); + }, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]); // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; @@ -453,13 +486,8 @@ export const CardDisplayComponent: React.FC = ({ additionalData: {}, })); useModalDataStore.getState().setData(tableNameToUse, modalItems); - console.log("[CardDisplay] modalDataStore에 데이터 저장:", { - dataSourceId: tableNameToUse, - count: modalItems.length, - }); } else if (tableNameToUse && selectedRowsData.length === 0) { useModalDataStore.getState().clearData(tableNameToUse); - console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse); } // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) @@ -467,13 +495,8 @@ export const CardDisplayComponent: React.FC = ({ if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (checked) { splitPanelContext.setSelectedLeftData(data); - console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", { - data, - parentDataMapping: splitPanelContext.parentDataMapping, - }); } else { splitPanelContext.setSelectedLeftData(null); - console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화"); } } }, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); @@ -540,21 +563,38 @@ export const CardDisplayComponent: React.FC = ({ }, [categoryMappings]); // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) - // 초기 로드 여부 추적 - const isInitialLoadRef = useRef(true); + // 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응) + const mountCountRef = useRef(0); useEffect(() => { + mountCountRef.current += 1; + const currentMount = mountCountRef.current; + if (!tableNameToUse || isDesignMode) return; - // 초기 로드는 별도 useEffect에서 처리하므로 스킵 - if (isInitialLoadRef.current) { - isInitialLoadRef.current = false; + // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 + const isRightPanel = splitPanelPosition === "right" || + (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); + const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + const hasSelectedLeftData = splitPanelContext?.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) + if (isRightPanel && !hasSelectedLeftData) { + // 데이터를 지우지 않고 로딩만 false로 설정 + setLoading(false); + return; + } + + // 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨) + // 필터 변경이 아닌 경우 스킵 + if (currentMount <= 2 && filters.length === 0) { return; } const loadFilteredData = async () => { try { - setLoading(true); + // 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지) // 필터 값을 검색 파라미터로 변환 const searchParams: Record = {}; @@ -564,12 +604,6 @@ export const CardDisplayComponent: React.FC = ({ } }); - console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", { - tableName: tableNameToUse, - filtersCount: filters.length, - searchParams, - }); - // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { page: 1, @@ -584,16 +618,14 @@ export const CardDisplayComponent: React.FC = ({ tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); } } catch (error) { - console.error("❌ [CardDisplay] 필터 적용 실패:", error); - } finally { - setLoading(false); + // 필터 적용 실패 시 무시 } }; // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) loadFilteredData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, tableNameToUse, isDesignMode, tableId]); + }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); // 컬럼 고유 값 조회 함수 (select 타입 필터용) const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { @@ -616,7 +648,6 @@ export const CardDisplayComponent: React.FC = ({ label: mapping?.[value]?.label || value, })); } catch (error) { - console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error); return []; } }, [tableNameToUse]); @@ -663,10 +694,6 @@ export const CardDisplayComponent: React.FC = ({ // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 const onFilterChangeWrapper = (newFilters: TableFilter[]) => { - console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", { - tableId, - filtersCount: newFilters.length, - }); setFiltersRef.current(newFilters); }; @@ -686,20 +713,12 @@ export const CardDisplayComponent: React.FC = ({ getColumnUniqueValues: getColumnUniqueValuesWrapper, }; - console.log("📋 [CardDisplay] TableOptionsContext에 등록:", { - tableId, - tableName: tableNameToUse, - columnsCount: columns.length, - dataCount: loadedTableData.length, - }); - registerTableRef.current(registration); const unregister = unregisterTableRef.current; const currentTableId = tableId; return () => { - console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId); unregister(currentTableId); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -711,8 +730,34 @@ export const CardDisplayComponent: React.FC = ({ columnsKey, // 컬럼 변경 시에만 재등록 ]); - // 로딩 중인 경우 로딩 표시 - if (loading) { + // 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 + // 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) + if (shouldHideDataForRightPanel) { + return ( +
+
+
좌측에서 항목을 선택해주세요
+
선택한 항목의 관련 데이터가 여기에 표시됩니다
+
+
+ ); + } + + // 로딩 중이고 데이터가 없는 경우에만 로딩 표시 + // 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) + if (loading && displayData.length === 0 && !hasEverSelectedLeftData) { return (
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 20aafd7f..41a477ab 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1268,18 +1268,9 @@ export const TableListComponent: React.FC = ({ }); } - console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, { - url: `/table-categories/${targetTable}/${targetColumn}/values`, - }); const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); - console.log(`📡 [TableList] API 응답 [${columnName}]:`, { - success: response.data.success, - dataLength: response.data.data?.length, - rawData: response.data, - items: response.data.data, - }); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1291,18 +1282,11 @@ export const TableListComponent: React.FC = ({ label: item.valueLabel, color: item.color, }; - console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`); }); if (Object.keys(mapping).length > 0) { // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; - console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, { - columnName, - mappingCount: Object.keys(mapping).length, - mappingKeys: Object.keys(mapping), - mapping, - }); } else { console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`); } @@ -1342,7 +1326,6 @@ export const TableListComponent: React.FC = ({ col.columnName, })) || []; - console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns); // 조인 테이블별로 그룹화 const joinedTableColumns: Record = {}; @@ -1375,7 +1358,6 @@ export const TableListComponent: React.FC = ({ }); } - console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns); // 조인된 테이블별로 inputType 정보 가져오기 const newJoinedColumnMeta: Record = {}; @@ -1421,9 +1403,6 @@ export const TableListComponent: React.FC = ({ if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; - console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, { - mappingCount: Object.keys(mapping).length, - }); } } } catch (error) { @@ -1442,16 +1421,9 @@ export const TableListComponent: React.FC = ({ console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta); } - console.log("📊 [TableList] 전체 카테고리 매핑 설정:", { - mappingsCount: Object.keys(mappings).length, - mappingsKeys: Object.keys(mappings), - mappings, - }); - if (Object.keys(mappings).length > 0) { setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); - console.log("✅ [TableList] setCategoryMappings 호출 완료"); } else { console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵"); } @@ -1473,11 +1445,6 @@ export const TableListComponent: React.FC = ({ // ======================================== const fetchTableDataInternal = useCallback(async () => { - console.log("📡 [TableList] fetchTableDataInternal 호출됨", { - tableName: tableConfig.selectedTable, - isDesignMode, - currentPage, - }); if (!tableConfig.selectedTable || isDesignMode) { setData([]); @@ -1501,13 +1468,6 @@ export const TableListComponent: React.FC = ({ let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", { - hasSplitPanelContext: !!splitPanelContext, - tableName: tableConfig.selectedTable, - selectedLeftData: splitPanelContext?.selectedLeftData, - linkedFilters: splitPanelContext?.linkedFilters, - splitPanelPosition: splitPanelPosition, - }); if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) @@ -1523,7 +1483,6 @@ export const TableListComponent: React.FC = ({ splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); - console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 @@ -1655,7 +1614,6 @@ export const TableListComponent: React.FC = ({ }; }); - console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) let excludeFilterParam: any = undefined; @@ -2146,16 +2104,6 @@ export const TableListComponent: React.FC = ({ // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - console.log("🔗 [TableList] 셀 클릭 - 분할 패널 위치 확인:", { - rowIndex, - colIndex, - splitPanelPosition, - currentSplitPosition, - effectiveSplitPosition, - hasSplitPanelContext: !!splitPanelContext, - isCurrentlySelected, - }); - if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 if (!isCurrentlySelected) { @@ -2165,10 +2113,6 @@ export const TableListComponent: React.FC = ({ // 분할 패널 컨텍스트에 데이터 저장 splitPanelContext.setSelectedLeftData(row); - console.log("🔗 [TableList] 셀 클릭으로 분할 패널 좌측 데이터 저장:", { - row, - parentDataMapping: splitPanelContext.parentDataMapping, - }); // onSelectedRowsChange 콜백 호출 if (onSelectedRowsChange) { @@ -2888,7 +2832,6 @@ export const TableListComponent: React.FC = ({ try { localStorage.setItem(tableStateKey, JSON.stringify(state)); - console.log("✅ 테이블 상태 저장:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } @@ -2930,7 +2873,6 @@ export const TableListComponent: React.FC = ({ setHeaderFilters(filters); } - console.log("✅ 테이블 상태 복원:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 복원 실패:", error); } @@ -2951,7 +2893,6 @@ export const TableListComponent: React.FC = ({ setShowGridLines(true); setHeaderFilters({}); toast.success("테이블 설정이 초기화되었습니다."); - console.log("✅ 테이블 상태 초기화:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); } diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index af16bcea..0dde5ea9 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -115,21 +115,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 필터링된 결과가 없으면 모든 테이블 반환 (폴백) if (filteredTables.length === 0) { - console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", { - targetPanelPosition, - allTablesCount: allTableList.length, - allTableIds: allTableList.map(t => t.tableId), - }); return allTableList; } - console.log("🔍 [TableSearchWidget] 테이블 필터링:", { - targetPanelPosition, - allTablesCount: allTableList.length, - filteredCount: filteredTables.length, - filteredTableIds: filteredTables.map(t => t.tableId), - }); - return filteredTables; }, [allTableList, targetPanelPosition]); @@ -159,11 +147,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택 if (!selectedTableId || !isCurrentTableInTarget) { const targetTable = tableList[0]; - console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", { - targetPanelPosition, - selectedTableId: targetTable.tableId, - tableName: targetTable.tableName, - }); setSelectedTableId(targetTable.tableId); } }, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]); @@ -374,12 +357,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return true; }); - console.log("🔍 [TableSearchWidget] 필터 적용:", { - currentTableId: currentTable?.tableId, - currentTableName: currentTable?.tableName, - filtersCount: filtersWithValues.length, - filtersWithValues, - }); currentTable?.onFilterChange(filtersWithValues); }; diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx index dc6ee110..654a22ef 100644 --- a/frontend/lib/registry/components/tabs/tabs-component.tsx +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -23,12 +23,6 @@ const TabsWidgetWrapper: React.FC = (props) => { persistSelection: tabsConfig.persistSelection || false, }; - console.log("🎨 TabsWidget 렌더링:", { - componentId: component.id, - tabs: tabsComponent.tabs, - tabsLength: tabsComponent.tabs.length, - component, - }); // TabsWidget 동적 로드 const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; From 342042d761d9f1091d967c2bf1c54d41d4f68f6b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 16 Dec 2025 13:58:30 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat(repeater-table):=20=ED=96=89=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=95=A4=20=EB=93=9C=EB=A1=AD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=AC=EB=9F=BC=20=EB=84=88=EB=B9=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @dnd-kit 라이브러리로 행 순서 드래그 앤 드롭 구현 - SortableRow 컴포넌트로 드래그 가능한 테이블 행 구현 - GripVertical 아이콘 드래그 핸들 추가 - 드래그 시 선택된 행 인덱스 자동 재계산 - "균등 분배" 버튼으로 컬럼 너비 컨테이너에 맞게 균등 분배 - 컬럼 헤더 더블클릭으로 데이터 기준 자동 확장/복구 토글 - Input 컴포넌트 min-w-0 w-full 적용으로 컬럼 너비 초과 방지 --- .../ModalRepeaterTableComponent.tsx | 26 +- .../modal-repeater-table/RepeaterTable.tsx | 375 ++++++++++++++---- 2 files changed, 330 insertions(+), 71 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 6177f647..c7d7c8b6 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; +import { Plus, Columns } from "lucide-react"; import { ItemSelectionModal } from "./ItemSelectionModal"; import { RepeaterTable } from "./RepeaterTable"; import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types"; @@ -331,6 +331,9 @@ export function ModalRepeaterTableComponent({ // 체크박스 선택 상태 const [selectedRows, setSelectedRows] = useState>(new Set()); + // 균등 분배 트리거 (값이 변경되면 RepeaterTable에서 균등 분배 실행) + const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0); + // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); @@ -820,9 +823,23 @@ export function ModalRepeaterTableComponent({
{/* 추가 버튼 */}
-
- {localValue.length > 0 && `${localValue.length}개 항목`} - {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} +
+ + {localValue.length > 0 && `${localValue.length}개 항목`} + {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} + + {columns.length > 0 && ( + + )}
{selectedRows.size > 0 && ( @@ -855,6 +872,7 @@ export function ModalRepeaterTableComponent({ onDataSourceChange={handleDataSourceChange} selectedRows={selectedRows} onSelectionChange={setSelectedRows} + equalizeWidthsTrigger={equalizeWidthsTrigger} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 1badecf9..4d6c9086 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -1,14 +1,68 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { ChevronDown, Check } from "lucide-react"; +import { ChevronDown, Check, GripVertical } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { RepeaterColumnConfig } from "./types"; import { cn } from "@/lib/utils"; +// @dnd-kit imports +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// SortableRow 컴포넌트 - 드래그 가능한 테이블 행 +interface SortableRowProps { + id: string; + children: (props: { + attributes: React.HTMLAttributes; + listeners: React.HTMLAttributes | undefined; + isDragging: boolean; + }) => React.ReactNode; + className?: string; +} + +function SortableRow({ id, children, className }: SortableRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + backgroundColor: isDragging ? "#f0f9ff" : undefined, + }; + + return ( +
+ {children({ attributes, listeners, isDragging })} + + ); +} + interface RepeaterTableProps { columns: RepeaterColumnConfig[]; data: any[]; @@ -21,6 +75,8 @@ interface RepeaterTableProps { // 체크박스 선택 관련 selectedRows: Set; // 선택된 행 인덱스 onSelectionChange: (selectedRows: Set) => void; // 선택 변경 콜백 + // 균등 분배 트리거 + equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행 } export function RepeaterTable({ @@ -33,7 +89,58 @@ export function RepeaterTable({ onDataSourceChange, selectedRows, onSelectionChange, + equalizeWidthsTrigger, }: RepeaterTableProps) { + // 컨테이너 ref - 실제 너비 측정용 + const containerRef = useRef(null); + + // 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤) + const [isEqualizedMode, setIsEqualizedMode] = useState(false); + + // DnD 센서 설정 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px 이동해야 드래그 시작 (클릭과 구분) + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 드래그 종료 핸들러 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = data.findIndex((_, idx) => `row-${idx}` === active.id); + const newIndex = data.findIndex((_, idx) => `row-${idx}` === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + const newData = arrayMove(data, oldIndex, newIndex); + onDataChange(newData); + + // 선택된 행 인덱스도 업데이트 + if (selectedRows.size > 0) { + const newSelectedRows = new Set(); + selectedRows.forEach((oldIdx) => { + if (oldIdx === oldIndex) { + newSelectedRows.add(newIndex); + } else if (oldIdx > oldIndex && oldIdx <= newIndex) { + newSelectedRows.add(oldIdx - 1); + } else if (oldIdx < oldIndex && oldIdx >= newIndex) { + newSelectedRows.add(oldIdx + 1); + } else { + newSelectedRows.add(oldIdx); + } + }); + onSelectionChange(newSelectedRows); + } + } + } + }; + const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string; @@ -71,15 +178,100 @@ export function RepeaterTable({ startX: e.clientX, startWidth: columnWidths[field] || 120, }); + // 수동 조정 시 균등 분배 모드 해제 + setIsEqualizedMode(false); }; - // 더블클릭으로 기본 너비로 리셋 - const handleDoubleClick = (field: string) => { - setColumnWidths((prev) => ({ - ...prev, - [field]: defaultWidths[field] || 120, - })); + // 컬럼 확장 상태 추적 (토글용) + const [expandedColumns, setExpandedColumns] = useState>(new Set()); + + // 데이터 기준 최적 너비 계산 + const calculateAutoFitWidth = (field: string): number => { + const column = columns.find(col => col.field === field); + if (!column) return 120; + + // 헤더 텍스트 길이 (대략 8px per character + padding) + const headerWidth = (column.label?.length || field.length) * 8 + 40; + + // 데이터 중 가장 긴 텍스트 찾기 + let maxDataWidth = 0; + data.forEach(row => { + const value = row[field]; + if (value !== undefined && value !== null) { + let displayText = String(value); + + // 숫자는 천단위 구분자 포함 + if (typeof value === 'number') { + displayText = value.toLocaleString(); + } + // 날짜는 yyyy-mm-dd 형식 + if (column.type === 'date' && displayText.includes('T')) { + displayText = displayText.split('T')[0]; + } + + // 대략적인 너비 계산 (8px per character + padding) + const textWidth = displayText.length * 8 + 32; + maxDataWidth = Math.max(maxDataWidth, textWidth); + } + }); + + // 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px + const optimalWidth = Math.max(headerWidth, maxDataWidth); + return Math.min(Math.max(optimalWidth, 60), 400); }; + + // 더블클릭으로 auto-fit / 기본 너비 토글 + const handleDoubleClick = (field: string) => { + // 개별 컬럼 조정 시 균등 분배 모드 해제 + setIsEqualizedMode(false); + + setExpandedColumns(prev => { + const newSet = new Set(prev); + if (newSet.has(field)) { + // 확장 상태 → 기본 너비로 복구 + newSet.delete(field); + setColumnWidths(prevWidths => ({ + ...prevWidths, + [field]: defaultWidths[field] || 120, + })); + } else { + // 기본 상태 → 데이터 기준 auto-fit + newSet.add(field); + const autoWidth = calculateAutoFitWidth(field); + setColumnWidths(prevWidths => ({ + ...prevWidths, + [field]: autoWidth, + })); + } + return newSet; + }); + }; + + // 균등 분배 트리거 감지 + useEffect(() => { + if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; + if (!containerRef.current) return; + + // 실제 컨테이너 너비 측정 + const containerWidth = containerRef.current.offsetWidth; + + // 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산 + const checkboxColumnWidth = 40; + const borderWidth = 2; + const availableWidth = containerWidth - checkboxColumnWidth - borderWidth; + + // 컬럼 수로 나눠서 균등 분배 (최소 60px 보장) + const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + + const newWidths: Record = {}; + columns.forEach((col) => { + newWidths[col.field] = equalWidth; + }); + + setColumnWidths(newWidths); + setExpandedColumns(new Set()); // 확장 상태 초기화 + setIsEqualizedMode(true); // 균등 분배 모드 활성화 + }, [equalizeWidthsTrigger, columns]); useEffect(() => { if (!resizing) return; @@ -176,7 +368,7 @@ export function RepeaterTable({ onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) } - className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" + className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" /> ); @@ -204,7 +396,7 @@ export function RepeaterTable({ type="date" value={formatDateValue(value)} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} - className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" + className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" /> ); @@ -216,7 +408,7 @@ export function RepeaterTable({ handleCellEdit(rowIndex, column.field, newValue) } > - + @@ -235,30 +427,49 @@ export function RepeaterTable({ type="text" value={value || ""} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} - className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" + className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" /> ); } }; + // 드래그 아이템 ID 목록 + const sortableItems = data.map((_, idx) => `row-${idx}`); + return ( -
-
-
- # + + - 삭제 -
추가된 항목이 없습니다 @@ -320,25 +358,25 @@ export function RepeaterTable({
- {rowIndex + 1} +
+ handleRowSelect(rowIndex, !!checked)} + className="border-gray-400" + /> {renderCell(row, col, rowIndex)} - -
- - - + +
+
+
- -
+ + + {/* 드래그 핸들 헤더 */} + + {/* 체크박스 헤더 */} + {columns.map((col) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; @@ -266,13 +477,15 @@ export function RepeaterTable({ ? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] : null; + const isExpanded = expandedColumns.has(col.field); + return ( ); })} - - - - {data.length === 0 ? ( - - - ) : ( - data.map((row, rowIndex) => ( - - - {columns.map((col) => ( - + {data.length === 0 ? ( + + - ))} - - )) - )} - -
+ 순서 + + + handleDoubleClick(col.field)} - title="더블클릭하여 기본 너비로 되돌리기" + title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"} >
@@ -344,46 +557,74 @@ export function RepeaterTable({
- 추가된 항목이 없습니다 -
- handleRowSelect(rowIndex, !!checked)} - className="border-gray-400" - /> - - {renderCell(row, col, rowIndex)} + + +
+ 추가된 항목이 없습니다
+ + ) : ( + data.map((row, rowIndex) => ( + + {({ attributes, listeners, isDragging }) => ( + <> + {/* 드래그 핸들 */} + + + + {/* 체크박스 */} + + handleRowSelect(rowIndex, !!checked)} + className="border-gray-400" + /> + + {/* 데이터 컬럼들 */} + {columns.map((col) => ( + + {renderCell(row, col, rowIndex)} + + ))} + + )} + + )) + )} + + + +
-
+ ); } From f7e3c1924c58e57412d246b0dfb8d8e7025b6ae9 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 14:38:03 +0900 Subject: [PATCH 21/29] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=EC=A0=80=EC=9E=A5=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/screenManagementService.ts | 2 +- docs/즉시저장_버튼_액션_구현_계획서.md | 345 +++++++++ .../screen/InteractiveScreenViewerDynamic.tsx | 216 ++++++ .../config-panels/ButtonConfigPanel.tsx | 16 +- .../config-panels/EntityConfigPanel.tsx | 27 + .../QuickInsertConfigSection.tsx | 658 ++++++++++++++++++ .../lib/registry/DynamicComponentRenderer.tsx | 5 +- .../button-primary/ButtonPrimaryComponent.tsx | 5 + .../card-display/CardDisplayComponent.tsx | 19 +- .../EntitySearchInputComponent.tsx | 229 +++++- .../EntitySearchInputConfigPanel.tsx | 9 +- .../components/entity-search-input/config.ts | 2 +- .../components/entity-search-input/types.ts | 7 +- frontend/lib/utils/buttonActions.ts | 349 +++++++++- frontend/lib/utils/webTypeMapping.ts | 2 +- frontend/types/screen-management.ts | 107 +++ frontend/types/unified-core.ts | 5 +- 17 files changed, 1969 insertions(+), 34 deletions(-) create mode 100644 docs/즉시저장_버튼_액션_구현_계획서.md create mode 100644 frontend/components/screen/config-panels/QuickInsertConfigSection.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 9fc0f079..92a35663 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1751,7 +1751,7 @@ export class ScreenManagementService { // 기타 label: "text-display", code: "select-basic", - entity: "select-basic", + entity: "entity-search-input", // 엔티티는 entity-search-input 사용 category: "select-basic", }; diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md new file mode 100644 index 00000000..6ce86286 --- /dev/null +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -0,0 +1,345 @@ +# 즉시 저장(quickInsert) 버튼 액션 구현 계획서 + +## 1. 개요 + +### 1.1 목적 +화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현 + +### 1.2 사용 사례 +- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장 + +### 1.3 화면 구성 예시 +``` +┌─────────────────────────────────────────────────────────────┐ +│ [entity 선택박스] [버튼: quickInsert] │ +│ ┌─────────────────────────────┐ ┌──────────────┐ │ +│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │ +│ └─────────────────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 기술 설계 + +### 2.1 버튼 액션 타입 추가 + +```typescript +// types/screen-management.ts +type ButtonActionType = + | "save" + | "cancel" + | "delete" + | "edit" + | "add" + | "search" + | "reset" + | "submit" + | "close" + | "popup" + | "navigate" + | "custom" + | "quickInsert" // 🆕 즉시 저장 +``` + +### 2.2 quickInsert 설정 구조 + +```typescript +interface QuickInsertColumnMapping { + targetColumn: string; // 저장할 테이블의 컬럼명 + sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; + + // sourceType별 추가 설정 + sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID + sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명 + fixedValue?: any; // fixed: 고정값 + userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode) +} + +interface QuickInsertConfig { + targetTable: string; // 저장할 테이블명 + columnMappings: QuickInsertColumnMapping[]; + + // 저장 후 동작 + afterInsert?: { + refreshRightPanel?: boolean; // 우측 패널 새로고침 + clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록 + showSuccessMessage?: boolean; // 성공 메시지 표시 + successMessage?: string; // 커스텀 성공 메시지 + }; + + // 중복 체크 (선택사항) + duplicateCheck?: { + enabled: boolean; + columns: string[]; // 중복 체크할 컬럼들 + errorMessage?: string; // 중복 시 에러 메시지 + }; +} + +interface ButtonComponentConfig { + // 기존 설정들... + actionType: ButtonActionType; + + // 🆕 quickInsert 전용 설정 + quickInsertConfig?: QuickInsertConfig; +} +``` + +### 2.3 데이터 흐름 + +``` +1. 사용자가 entity 선택박스에서 설비 선택 + └─ equipment_code = "EQ-001" (내부값) + └─ 표시: "MCT-01 - 머시닝센터 #1" + +2. 사용자가 "설비 추가" 버튼 클릭 + +3. quickInsert 핸들러 실행 + ├─ columnMappings 순회 + │ ├─ equipment_code: component에서 값 가져오기 → "EQ-001" + │ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001" + │ + └─ INSERT 데이터 구성 + { + equipment_code: "EQ-001", + process_code: "PRC-001", + company_code: "COMPANY_7", // 자동 추가 + writer: "wace" // 자동 추가 + } + +4. API 호출: POST /api/table-management/tables/process_equipment/add + +5. 성공 시 + ├─ 성공 메시지 표시 + ├─ 우측 패널(카드/테이블) 새로고침 + └─ 선택박스 초기화 +``` + +--- + +## 3. 구현 계획 + +### 3.1 Phase 1: 타입 정의 및 설정 UI + +| 작업 | 파일 | 설명 | +|------|------|------| +| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 | +| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 | + +### 3.2 Phase 2: 버튼 액션 핸들러 구현 + +| 작업 | 파일 | 설명 | +|------|------|------| +| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 | +| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 | + +### 3.3 Phase 3: 테스트 및 검증 + +| 작업 | 설명 | +|------|------| +| 3-1 | 공정별 설비 화면에서 테스트 | +| 3-2 | 중복 저장 방지 테스트 | +| 3-3 | 에러 처리 테스트 | + +--- + +## 4. 상세 구현 + +### 4.1 ButtonConfigPanel 설정 UI + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 버튼 액션 타입 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 즉시 저장 (quickInsert) ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 즉시 저장 설정 ─────────────── │ +│ │ +│ 대상 테이블 * │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ process_equipment ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 컬럼 매핑 [+ 추가] │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #1 [삭제] │ │ +│ │ 대상 컬럼: equipment_code │ │ +│ │ 값 소스: 컴포넌트 선택 │ │ +│ │ 컴포넌트: [equipment-select ▼] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #2 [삭제] │ │ +│ │ 대상 컬럼: process_code │ │ +│ │ 값 소스: 좌측 패널 데이터 │ │ +│ │ 소스 컬럼: process_code │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 저장 후 동작 ─────────────── │ +│ │ +│ ☑ 우측 패널 새로고침 │ +│ ☑ 선택박스 초기화 │ +│ ☑ 성공 메시지 표시 │ +│ │ +│ ─────────────── 중복 체크 (선택) ─────────────── │ +│ │ +│ ☐ 중복 체크 활성화 │ +│ 체크 컬럼: equipment_code, process_code │ +│ 에러 메시지: 이미 등록된 설비입니다. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 핸들러 구현 (의사 코드) + +```typescript +const handleQuickInsert = async (config: QuickInsertConfig) => { + // 1. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + + for (const mapping of config.columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + value = getComponentValue(mapping.sourceComponentId); + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn]; + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + value = user?.[mapping.userField]; + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 2. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다."); + return; + } + + // 3. 중복 체크 (설정된 경우) + if (config.duplicateCheck?.enabled) { + const isDuplicate = await checkDuplicate( + config.targetTable, + config.duplicateCheck.columns, + insertData + ); + if (isDuplicate) { + toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } + + // 4. API 호출 + try { + await tableTypeApi.addTableData(config.targetTable, insertData); + + // 5. 성공 후 동작 + if (config.afterInsert?.showSuccessMessage) { + toast.success(config.afterInsert.successMessage || "저장되었습니다."); + } + + if (config.afterInsert?.refreshRightPanel) { + // 우측 패널 새로고침 트리거 + onRefresh?.(); + } + + if (config.afterInsert?.clearComponents) { + // 지정된 컴포넌트 초기화 + for (const componentId of config.afterInsert.clearComponents) { + clearComponentValue(componentId); + } + } + + } catch (error) { + toast.error("저장에 실패했습니다."); + } +}; +``` + +--- + +## 5. 컴포넌트 간 통신 방안 + +### 5.1 문제점 +- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함 +- 현재는 각 컴포넌트가 독립적으로 동작 + +### 5.2 해결 방안: formData 활용 + +현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음. + +```typescript +// InteractiveScreenViewerDynamic.tsx +const [localFormData, setLocalFormData] = useState>({}); + +// entity 선택박스에서 값 변경 시 +const handleFormDataChange = (fieldName: string, value: any) => { + setLocalFormData(prev => ({ ...prev, [fieldName]: value })); +}; + +// 버튼 클릭 시 formData에서 값 가져오기 +const getComponentValue = (componentId: string) => { + // componentId로 컴포넌트의 columnName 찾기 + const component = allComponents.find(c => c.id === componentId); + if (component?.columnName) { + return formData[component.columnName]; + } + return undefined; +}; +``` + +--- + +## 6. 테스트 시나리오 + +### 6.1 정상 케이스 +1. 좌측 테이블에서 공정 "PRC-001" 선택 +2. 우측 설비 선택박스에서 "MCT-01" 선택 +3. "설비 추가" 버튼 클릭 +4. `process_equipment` 테이블에 데이터 저장 확인 +5. 우측 카드/테이블에 새 항목 표시 확인 + +### 6.2 에러 케이스 +1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지 +2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지 +3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지 + +### 6.3 엣지 케이스 +1. 동일 설비 연속 추가 시도 +2. 네트워크 오류 시 재시도 +3. 권한 없는 사용자의 저장 시도 + +--- + +## 7. 일정 + +| Phase | 작업 | 예상 시간 | +|-------|------|----------| +| Phase 1 | 타입 정의 및 설정 UI | 1시간 | +| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 | +| Phase 3 | 테스트 및 검증 | 30분 | +| **합계** | | **2시간 30분** | + +--- + +## 8. 향후 확장 가능성 + +1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가 +2. **수정 모드**: 기존 데이터 수정 기능 +3. **조건부 저장**: 특정 조건 만족 시에만 저장 +4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 97dc0734..5b09b092 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -584,6 +584,219 @@ export const InteractiveScreenViewerDynamic: React.FC { + // componentConfig에서 quickInsertConfig 가져오기 + const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig; + + if (!quickInsertConfig?.targetTable) { + toast.error("대상 테이블이 설정되지 않았습니다."); + return; + } + + // 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용) + let targetTableColumns: string[] = []; + try { + const { default: apiClient } = await import("@/lib/api/client"); + const columnsResponse = await apiClient.get( + `/table-management/tables/${quickInsertConfig.targetTable}/columns` + ); + if (columnsResponse.data?.success && columnsResponse.data?.data) { + const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data; + targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name); + console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns); + } + } catch (error) { + console.error("대상 테이블 컬럼 조회 실패:", error); + } + + // 2. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + const columnMappings = quickInsertConfig.columnMappings || []; + + for (const mapping of columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + // 방법1: sourceColumnName 사용 + if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) { + value = formData[mapping.sourceColumnName]; + console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); + } + // 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용 + else if (mapping.sourceComponentId) { + const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId); + if (sourceComp) { + const fieldName = (sourceComp as any).columnName || sourceComp.id; + value = formData[fieldName]; + console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`); + } + } + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) { + value = splitPanelContext.selectedLeftData[mapping.sourceColumn]; + } + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + if (mapping.userField) { + switch (mapping.userField) { + case "userId": + value = user?.userId; + break; + case "userName": + value = userName; + break; + case "companyCode": + value = user?.companyCode; + break; + case "deptCode": + value = authUser?.deptCode; + break; + } + } + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우) + if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) { + const leftData = splitPanelContext.selectedLeftData; + console.log("📍 좌측 패널 자동 매핑 시작:", leftData); + + for (const [key, val] of Object.entries(leftData)) { + // 이미 매핑된 컬럼은 스킵 + if (insertData[key] !== undefined) { + continue; + } + + // 대상 테이블에 해당 컬럼이 없으면 스킵 + if (!targetTableColumns.includes(key)) { + continue; + } + + // 시스템 컬럼 제외 + const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; + if (systemColumns.includes(key)) { + continue; + } + + // _label, _name 으로 끝나는 표시용 컬럼 제외 + if (key.endsWith('_label') || key.endsWith('_name')) { + continue; + } + + // 값이 있으면 자동 추가 + if (val !== undefined && val !== null && val !== '') { + insertData[key] = val; + console.log(`📍 자동 매핑 추가: ${key} = ${val}`); + } + } + } + + console.log("🚀 quickInsert 최종 데이터:", insertData); + + // 4. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다. 값을 선택해주세요."); + return; + } + + // 5. 중복 체크 (설정된 경우) + if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { + try { + const { default: apiClient } = await import("@/lib/api/client"); + + // 중복 체크를 위한 검색 조건 구성 + const searchConditions: Record = {}; + for (const col of quickInsertConfig.duplicateCheck.columns) { + if (insertData[col] !== undefined) { + searchConditions[col] = { value: insertData[col], operator: "equals" }; + } + } + + console.log("📍 중복 체크 조건:", searchConditions); + + // 기존 데이터 조회 + const checkResponse = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/data`, + { + page: 1, + pageSize: 1, + search: searchConditions, + } + ); + + console.log("📍 중복 체크 응답:", checkResponse.data); + + // data 배열이 있고 길이가 0보다 크면 중복 + const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; + if (Array.isArray(existingData) && existingData.length > 0) { + toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } catch (error) { + console.error("중복 체크 오류:", error); + // 중복 체크 실패 시 계속 진행 + } + } + + // 6. API 호출 + try { + const { default: apiClient } = await import("@/lib/api/client"); + + const response = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/add`, + insertData + ); + + if (response.data?.success) { + // 7. 성공 후 동작 + if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) { + toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다."); + } + + // 데이터 새로고침 (테이블리스트, 카드 디스플레이) + if (quickInsertConfig.afterInsert?.refreshData !== false) { + console.log("📍 데이터 새로고침 이벤트 발송"); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("refreshTable")); + window.dispatchEvent(new CustomEvent("refreshCardDisplay")); + } + } + + // 지정된 컴포넌트 초기화 + if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) { + for (const componentId of quickInsertConfig.afterInsert.clearComponents) { + const targetComp = allComponents.find((c: any) => c.id === componentId); + if (targetComp) { + const fieldName = (targetComp as any).columnName || targetComp.id; + onFormDataChange?.(fieldName, ""); + } + } + } + } else { + toast.error(response.data?.message || "저장에 실패했습니다."); + } + } catch (error: any) { + console.error("quickInsert 오류:", error); + toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다."); + } + }; + const handleClick = async () => { try { const actionType = config?.actionType || "save"; @@ -604,6 +817,9 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ 편집 복사 (품목코드 초기화) 페이지 이동 - 📦 데이터 전달 - 데이터 전달 + 모달 열기 🆕 + 데이터 전달 + 데이터 전달 + 모달 열기 모달 열기 + 즉시 저장 제어 흐름 테이블 이력 보기 엑셀 다운로드 @@ -3068,6 +3070,16 @@ export const ButtonConfigPanel: React.FC = ({
)} + {/* 🆕 즉시 저장(quickInsert) 액션 설정 */} + {component.componentConfig?.action?.type === "quickInsert" && ( + + )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/components/screen/config-panels/EntityConfigPanel.tsx b/frontend/components/screen/config-panels/EntityConfigPanel.tsx index 7c1b74eb..edb278f2 100644 --- a/frontend/components/screen/config-panels/EntityConfigPanel.tsx +++ b/frontend/components/screen/config-panels/EntityConfigPanel.tsx @@ -189,6 +189,33 @@ export const EntityConfigPanel: React.FC = ({

기본 설정

+ {/* UI 모드 선택 */} +
+ + +

+ {(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."} + {(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."} + {((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."} + {(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."} +

+
+