diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index a5b2f225..47ee4e94 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -216,7 +216,7 @@ export const deleteFormData = async ( ): Promise => { try { const { id } = req.params; - const { companyCode } = req.user as any; + const { companyCode, userId } = req.user as any; const { tableName } = req.body; if (!tableName) { @@ -226,7 +226,7 @@ export const deleteFormData = async ( }); } - await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원 + await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가 res.json({ success: true, diff --git a/backend-node/src/services/dataflowControlService.ts b/backend-node/src/services/dataflowControlService.ts index d4ffcffe..75ff0a5c 100644 --- a/backend-node/src/services/dataflowControlService.ts +++ b/backend-node/src/services/dataflowControlService.ts @@ -64,7 +64,8 @@ export class DataflowControlService { relationshipId: string, triggerType: "insert" | "update" | "delete", sourceData: Record, - tableName: string + tableName: string, + userId: string = "system" ): Promise<{ success: boolean; message: string; @@ -78,6 +79,7 @@ export class DataflowControlService { triggerType, sourceData, tableName, + userId, }); // 관계도 정보 조회 @@ -238,7 +240,8 @@ export class DataflowControlService { const actionResult = await this.executeMultiConnectionAction( action, sourceData, - targetPlan.sourceTable + targetPlan.sourceTable, + userId ); executedActions.push({ @@ -288,7 +291,8 @@ export class DataflowControlService { private async executeMultiConnectionAction( action: ControlAction, sourceData: Record, - sourceTable: string + sourceTable: string, + userId: string = "system" ): Promise { try { const extendedAction = action as any; // redesigned UI 구조 접근 @@ -321,7 +325,8 @@ export class DataflowControlService { targetTable, fromConnection.id, toConnection.id, - multiConnService + multiConnService, + userId ); case "update": @@ -332,7 +337,8 @@ export class DataflowControlService { targetTable, fromConnection.id, toConnection.id, - multiConnService + multiConnService, + userId ); case "delete": @@ -343,7 +349,8 @@ export class DataflowControlService { targetTable, fromConnection.id, toConnection.id, - multiConnService + multiConnService, + userId ); default: @@ -368,7 +375,8 @@ export class DataflowControlService { targetTable: string, fromConnectionId: number, toConnectionId: number, - multiConnService: any + multiConnService: any, + userId: string = "system" ): Promise { try { // 필드 매핑 적용 @@ -387,6 +395,14 @@ export class DataflowControlService { } } + // 🆕 변경자 정보 추가 + if (!mappedData.created_by) { + mappedData.created_by = userId; + } + if (!mappedData.updated_by) { + mappedData.updated_by = userId; + } + console.log(`📋 매핑된 데이터:`, mappedData); // 대상 연결에 데이터 삽입 @@ -421,11 +437,32 @@ export class DataflowControlService { targetTable: string, fromConnectionId: number, toConnectionId: number, - multiConnService: any + multiConnService: any, + userId: string = "system" ): Promise { try { - // UPDATE 로직 구현 (향후 확장) + // 필드 매핑 적용 + const mappedData: Record = {}; + + for (const mapping of action.fieldMappings) { + const sourceField = mapping.sourceField; + const targetField = mapping.targetField; + + if (mapping.defaultValue !== undefined) { + mappedData[targetField] = mapping.defaultValue; + } else if (sourceField && sourceData[sourceField] !== undefined) { + mappedData[targetField] = sourceData[sourceField]; + } + } + + // 🆕 변경자 정보 추가 + if (!mappedData.updated_by) { + mappedData.updated_by = userId; + } + + console.log(`📋 UPDATE 매핑된 데이터:`, mappedData); console.log(`⚠️ UPDATE 액션은 향후 구현 예정`); + return { success: true, message: "UPDATE 액션 실행됨 (향후 구현)", @@ -449,11 +486,11 @@ export class DataflowControlService { targetTable: string, fromConnectionId: number, toConnectionId: number, - multiConnService: any + multiConnService: any, + userId: string = "system" ): Promise { try { - // DELETE 로직 구현 (향후 확장) - console.log(`⚠️ DELETE 액션은 향후 구현 예정`); + console.log(`⚠️ DELETE 액션은 향후 구현 예정 (변경자: ${userId})`); return { success: true, message: "DELETE 액션 실행됨 (향후 구현)", @@ -941,7 +978,9 @@ export class DataflowControlService { sourceData: Record ): Promise { // 보안상 외부 DB에 대한 DELETE 작업은 비활성화 - throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."); + throw new Error( + "보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요." + ); const results = []; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 999ea6d2..8a943b96 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -220,8 +220,14 @@ export class DynamicFormService { console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys); // 메타데이터 제거 (실제 테이블 컬럼이 아님) - const { created_by, updated_by, company_code, screen_id, ...actualData } = - data; + const { + created_by, + updated_by, + writer, + company_code, + screen_id, + ...actualData + } = data; // 기본 데이터 준비 const dataToInsert: any = { ...actualData }; @@ -236,8 +242,17 @@ export class DynamicFormService { if (tableColumns.includes("regdate") && !dataToInsert.regdate) { dataToInsert.regdate = new Date(); } + if (tableColumns.includes("created_date") && !dataToInsert.created_date) { + dataToInsert.created_date = new Date(); + } + if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) { + dataToInsert.updated_date = new Date(); + } - // 생성자/수정자 정보가 있고 해당 컬럼이 존재한다면 추가 + // 작성자 정보 추가 (writer 컬럼 우선, 없으면 created_by/updated_by) + if (writer && tableColumns.includes("writer")) { + dataToInsert.writer = writer; + } if (created_by && tableColumns.includes("created_by")) { dataToInsert.created_by = created_by; } @@ -579,7 +594,8 @@ export class DynamicFormService { screenId, tableName, insertedRecord as Record, - "insert" + "insert", + created_by || "system" ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -876,7 +892,8 @@ export class DynamicFormService { 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, updatedRecord as Record, - "update" + "update", + updated_by || "system" ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -905,7 +922,8 @@ export class DynamicFormService { async deleteFormData( id: string | number, tableName: string, - companyCode?: string + companyCode?: string, + userId?: string ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { @@ -1010,7 +1028,8 @@ export class DynamicFormService { 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, deletedRecord, - "delete" + "delete", + userId || "system" ); } } catch (controlError) { @@ -1315,7 +1334,8 @@ export class DynamicFormService { screenId: number, tableName: string, savedData: Record, - triggerType: "insert" | "update" | "delete" + triggerType: "insert" | "update" | "delete", + userId: string = "system" ): Promise { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); @@ -1364,7 +1384,8 @@ export class DynamicFormService { relationshipId, triggerType, savedData, - tableName + tableName, + userId ); console.log(`🎯 제어관리 실행 결과:`, controlResult); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index a4f6ace4..353ee997 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -16,12 +16,16 @@ import { FlowVisibilityConfig } from "@/types/control-management"; import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; +import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보 export default function ScreenViewPage() { const params = useParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); + // 🆕 현재 로그인한 사용자 정보 + const { user, userName, companyCode } = useAuth(); + const [screen, setScreen] = useState(null); const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); @@ -270,6 +274,9 @@ export default function ScreenViewPage() { onClick={() => {}} screenId={screenId} tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} selectedRowsData={selectedRowsData} onSelectedRowsChange={(_, selectedData) => { console.log("🔍 화면에서 선택된 행 데이터:", selectedData); @@ -330,6 +337,9 @@ export default function ScreenViewPage() { onClick={() => {}} screenId={screenId} tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} selectedRowsData={selectedRowsData} onSelectedRowsChange={(_, selectedData) => { console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); @@ -434,6 +444,9 @@ export default function ScreenViewPage() { onDataflowComplete={() => {}} screenId={screenId} tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} selectedRowsData={selectedRowsData} onSelectedRowsChange={(_, selectedData) => { setSelectedRowsData(selectedData); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index bc483325..843e88e6 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -35,6 +35,8 @@ export const ScreenModal: React.FC = ({ className }) => { const [screenDimensions, setScreenDimensions] = useState<{ width: number; height: number; + offsetX?: number; + offsetY?: number; } | null>(null); // 폼 데이터 상태 추가 @@ -42,11 +44,20 @@ export const ScreenModal: React.FC = ({ className }) => { // 화면의 실제 크기 계산 함수 const calculateScreenDimensions = (components: ComponentData[]) => { + if (components.length === 0) { + return { + width: 400, + height: 300, + offsetX: 0, + offsetY: 0, + }; + } + // 모든 컴포넌트의 경계 찾기 let minX = Infinity; let minY = Infinity; - let maxX = 0; - let maxY = 0; + let maxX = -Infinity; + let maxY = -Infinity; components.forEach((component) => { const x = parseFloat(component.position?.x?.toString() || "0"); @@ -60,17 +71,22 @@ export const ScreenModal: React.FC = ({ className }) => { maxY = Math.max(maxY, y + height); }); - // 컨텐츠 실제 크기 + 넉넉한 여백 (양쪽 각 64px) + // 실제 컨텐츠 크기 계산 const contentWidth = maxX - minX; const contentHeight = maxY - minY; - const padding = 128; // 좌우 또는 상하 합계 여백 - const finalWidth = Math.max(contentWidth + padding, 400); // 최소 400px - const finalHeight = Math.max(contentHeight + padding, 300); // 최소 300px + // 적절한 여백 추가 + const paddingX = 40; + const paddingY = 40; + + const finalWidth = Math.max(contentWidth + paddingX, 400); + const finalHeight = Math.max(contentHeight + paddingY, 300); return { - width: Math.min(finalWidth, window.innerWidth * 0.98), - height: Math.min(finalHeight, window.innerHeight * 0.95), + width: Math.min(finalWidth, window.innerWidth * 0.95), + height: Math.min(finalHeight, window.innerHeight * 0.9), + offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려 + offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려 }; }; @@ -172,20 +188,20 @@ export const ScreenModal: React.FC = ({ className }) => { const getModalStyle = () => { if (!screenDimensions) { return { - className: "w-fit min-w-[400px] max-w-4xl max-h-[80vh] overflow-hidden", + className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", style: {}, }; } - // 헤더 높이만 고려 (패딩 제거) - const headerHeight = 73; // DialogHeader 실제 높이 (border-b px-6 py-4 포함) + // 헤더 높이를 최소화 (제목 영역만) + const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩) const totalHeight = screenDimensions.height + headerHeight; return { className: "overflow-hidden p-0", style: { - width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, // 화면 크기 그대로 - height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 헤더 + 화면 높이 + width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, maxWidth: "98vw", maxHeight: "95vh", }, @@ -197,12 +213,14 @@ export const ScreenModal: React.FC = ({ className }) => { return ( - - {modalState.title} - {loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."} + + {modalState.title} + {loading && ( + {loading ? "화면을 불러오는 중입니다..." : ""} + )} -
+
{loading ? (
@@ -216,35 +234,50 @@ export const ScreenModal: React.FC = ({ className }) => { style={{ width: screenDimensions?.width || 800, height: screenDimensions?.height || 600, - transformOrigin: 'center center', - maxWidth: '100%', - maxHeight: '100%', + transformOrigin: "center center", + maxWidth: "100%", + maxHeight: "100%", }} > - {screenData.components.map((component) => ( - { - console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - console.log("📝 ScreenModal 업데이트된 formData:", newFormData); - return newFormData; - }); - }} - screenInfo={{ - id: modalState.screenId!, - tableName: screenData.screenInfo?.tableName, - }} - /> - ))} + {screenData.components.map((component) => { + // 컴포넌트 위치를 offset만큼 조정 (왼쪽 상단으로 정렬) + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; + + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; + + return ( + { + console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); + console.log("📋 현재 formData:", formData); + setFormData((prev) => { + const newFormData = { + ...prev, + [fieldName]: value, + }; + console.log("📝 ScreenModal 업데이트된 formData:", newFormData); + return newFormData; + }); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData.screenInfo?.tableName, + }} + /> + ); + })}
) : (
diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index 5d0064ea..c086e922 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -13,12 +13,14 @@ import { useReactFlow } from "reactflow"; import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog"; import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation"; +import { useToast } from "@/hooks/use-toast"; interface FlowToolbarProps { validations?: FlowValidation[]; } export function FlowToolbar({ validations = [] }: FlowToolbarProps) { + const { toast } = useToast(); const { zoomIn, zoomOut, fitView } = useReactFlow(); const { flowName, @@ -56,9 +58,17 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) { const performSave = async () => { const result = await saveFlow(); if (result.success) { - alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`); + toast({ + title: "✅ 플로우 저장 완료", + description: `${result.message}\nFlow ID: ${result.flowId}`, + variant: "default", + }); } else { - alert(`❌ 저장 실패\n\n${result.message}`); + toast({ + title: "❌ 저장 실패", + description: result.message, + variant: "destructive", + }); } setShowSaveDialog(false); }; @@ -72,18 +82,30 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) { a.download = `${flowName || "flow"}.json`; a.click(); URL.revokeObjectURL(url); - alert("✅ JSON 파일로 내보내기 완료!"); + toast({ + title: "✅ 내보내기 완료", + description: "JSON 파일로 저장되었습니다.", + variant: "default", + }); }; const handleDelete = () => { if (selectedNodes.length === 0) { - alert("삭제할 노드를 선택해주세요."); + toast({ + title: "⚠️ 선택된 노드 없음", + description: "삭제할 노드를 선택해주세요.", + variant: "default", + }); return; } if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) { removeNodes(selectedNodes); - alert(`✅ ${selectedNodes.length}개 노드가 삭제되었습니다.`); + toast({ + title: "✅ 노드 삭제 완료", + description: `${selectedNodes.length}개 노드가 삭제되었습니다.`, + variant: "default", + }); } }; diff --git a/frontend/components/dataflow/node-editor/ValidationNotification.tsx b/frontend/components/dataflow/node-editor/ValidationNotification.tsx index 54a04625..39ff2253 100644 --- a/frontend/components/dataflow/node-editor/ValidationNotification.tsx +++ b/frontend/components/dataflow/node-editor/ValidationNotification.tsx @@ -18,189 +18,178 @@ interface ValidationNotificationProps { onClose?: () => void; } -export const ValidationNotification = memo( - ({ validations, onNodeClick, onClose }: ValidationNotificationProps) => { - const [isExpanded, setIsExpanded] = useState(false); - const summary = summarizeValidations(validations); +export const ValidationNotification = memo(({ validations, onNodeClick, onClose }: ValidationNotificationProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const summary = summarizeValidations(validations); - if (validations.length === 0) { - return null; - } + if (validations.length === 0) { + return null; + } - const getTypeLabel = (type: string): string => { - const labels: Record = { - "parallel-conflict": "병렬 실행 충돌", - "missing-where": "WHERE 조건 누락", - "circular-reference": "순환 참조", - "data-source-mismatch": "데이터 소스 불일치", - "parallel-table-access": "병렬 테이블 접근", - }; - return labels[type] || type; + const getTypeLabel = (type: string): string => { + const labels: Record = { + "disconnected-node": "연결되지 않은 노드", + "parallel-conflict": "병렬 실행 충돌", + "missing-where": "WHERE 조건 누락", + "circular-reference": "순환 참조", + "data-source-mismatch": "데이터 소스 불일치", + "parallel-table-access": "병렬 테이블 접근", }; + return labels[type] || type; + }; - // 타입별로 그룹화 - const groupedValidations = validations.reduce((acc, validation) => { + // 타입별로 그룹화 + const groupedValidations = validations.reduce( + (acc, validation) => { if (!acc[validation.type]) { acc[validation.type] = []; } acc[validation.type].push(validation); return acc; - }, {} as Record); + }, + {} as Record, + ); - return ( -
+ return ( +
+
0 + ? "border-yellow-500" + : "border-blue-500", + )} + > + {/* 헤더 */}
0 - ? "border-yellow-500" - : "border-blue-500" + "flex cursor-pointer items-center justify-between p-3", + summary.hasBlockingIssues ? "bg-red-50" : summary.warningCount > 0 ? "bg-yellow-50" : "bg-blue-50", )} + onClick={() => setIsExpanded(!isExpanded)} > - {/* 헤더 */} -
0 - ? "bg-yellow-50" - : "bg-blue-50" +
+ {summary.hasBlockingIssues ? ( + + ) : summary.warningCount > 0 ? ( + + ) : ( + )} - onClick={() => setIsExpanded(!isExpanded)} - > -
- {summary.hasBlockingIssues ? ( - - ) : summary.warningCount > 0 ? ( - - ) : ( - - )} - - 플로우 검증 - -
- {summary.errorCount > 0 && ( - - {summary.errorCount} - - )} - {summary.warningCount > 0 && ( - - {summary.warningCount} - - )} - {summary.infoCount > 0 && ( - - {summary.infoCount} - - )} -
-
+ 플로우 검증
- {isExpanded ? ( - - ) : ( - + {summary.errorCount > 0 && ( + + {summary.errorCount} + )} - {onClose && ( - + {summary.warningCount > 0 && ( + {summary.warningCount} + )} + {summary.infoCount > 0 && ( + + {summary.infoCount} + )}
- - {/* 확장된 내용 */} - {isExpanded && ( -
-
- {Object.entries(groupedValidations).map(([type, typeValidations]) => { - const firstValidation = typeValidations[0]; - const Icon = - firstValidation.severity === "error" - ? AlertCircle - : firstValidation.severity === "warning" - ? AlertTriangle - : Info; - - return ( -
- {/* 타입 헤더 */} -
- - {getTypeLabel(type)} - - {typeValidations.length}개 - -
- - {/* 검증 항목들 */} -
- {typeValidations.map((validation, index) => ( -
onNodeClick?.(validation.nodeId)} - > -

- {validation.message} -

- {validation.affectedNodes && validation.affectedNodes.length > 1 && ( -
- 영향받는 노드: {validation.affectedNodes.length}개 -
- )} -
- 클릭하여 노드 보기 → -
-
- ))} -
-
- ); - })} -
-
- )} - - {/* 요약 메시지 (닫혀있을 때) */} - {!isExpanded && ( -
-

- {summary.hasBlockingIssues - ? "⛔ 오류를 해결해야 저장할 수 있습니다" - : summary.warningCount > 0 - ? "⚠️ 경고 사항을 확인하세요" - : "ℹ️ 정보를 확인하세요"} -

-
- )} +
+ {isExpanded ? ( + + ) : ( + + )} + {onClose && ( + + )} +
+ + {/* 확장된 내용 */} + {isExpanded && ( +
+
+ {Object.entries(groupedValidations).map(([type, typeValidations]) => { + const firstValidation = typeValidations[0]; + const Icon = + firstValidation.severity === "error" + ? AlertCircle + : firstValidation.severity === "warning" + ? AlertTriangle + : Info; + + return ( +
+ {/* 타입 헤더 */} +
+ + {getTypeLabel(type)} + {typeValidations.length}개 +
+ + {/* 검증 항목들 */} +
+ {typeValidations.map((validation, index) => ( +
onNodeClick?.(validation.nodeId)} + > +

{validation.message}

+ {validation.affectedNodes && validation.affectedNodes.length > 1 && ( +
+ 영향받는 노드: {validation.affectedNodes.length}개 +
+ )} +
+ 클릭하여 노드 보기 → +
+
+ ))} +
+
+ ); + })} +
+
+ )} + + {/* 요약 메시지 (닫혀있을 때) */} + {!isExpanded && ( +
+

+ {summary.hasBlockingIssues + ? "⛔ 오류를 해결해야 저장할 수 있습니다" + : summary.warningCount > 0 + ? "⚠️ 경고 사항을 확인하세요" + : "ℹ️ 정보를 확인하세요"} +

+
+ )}
- ); - } -); +
+ ); +}); ValidationNotification.displayName = "ValidationNotification"; - diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index a4e47c1b..8b02211e 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1359,13 +1359,28 @@ export const InteractiveScreenViewer: React.FC = ( allComponents.find(c => c.columnName)?.tableName || "dynamic_form_data"; // 기본값 + // 🆕 자동으로 작성자 정보 추가 + const writerValue = user?.userId || userName || "unknown"; + console.log("👤 현재 사용자 정보:", { + userId: user?.userId, + userName: userName, + writerValue: writerValue, + }); + + const dataWithUserInfo = { + ...mappedData, + writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼 + created_by: writerValue, + updated_by: writerValue, + }; + const saveData: DynamicFormData = { screenId: screenInfo.id, tableName: tableName, - data: mappedData, + data: dataWithUserInfo, }; - // console.log("🚀 API 저장 요청:", saveData); + console.log("🚀 API 저장 요청:", saveData); const result = await dynamicFormApi.saveFormData(saveData); @@ -1859,12 +1874,12 @@ export const InteractiveScreenViewer: React.FC = ( setPopupScreen(null); setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 }}> - - + + {popupScreen?.title || "상세 정보"} -
+
{popupLoading ? (
화면을 불러오는 중...
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index c616e940..7ed39353 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -180,16 +180,6 @@ export const InteractiveScreenViewerDynamic: React.FC { - console.log("🔄 버튼에서 테이블 새로고침 요청됨"); // 테이블 컴포넌트는 자체적으로 loadData 호출 }} onClose={() => { diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 31f493e7..df14084b 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -38,6 +38,9 @@ interface RealtimePreviewProps { // 버튼 액션을 위한 props screenId?: number; tableName?: string; + userId?: string; // 🆕 현재 사용자 ID + userName?: string; // 🆕 현재 사용자 이름 + companyCode?: string; // 🆕 현재 사용자의 회사 코드 selectedRowsData?: any[]; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; flowSelectedData?: any[]; @@ -96,6 +99,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onConfigChange, screenId, tableName, + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 selectedRowsData, onSelectedRowsChange, flowSelectedData, @@ -291,6 +297,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onConfigChange={onConfigChange} screenId={screenId} tableName={tableName} + userId={userId} + userName={userName} + companyCode={companyCode} selectedRowsData={selectedRowsData} onSelectedRowsChange={onSelectedRowsChange} flowSelectedData={flowSelectedData} diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index ddd55237..271a83d9 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -10,6 +10,7 @@ import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { screenApi } from "@/lib/api/screen"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { ComponentData } from "@/lib/types/screen"; +import { useAuth } from "@/hooks/useAuth"; interface SaveModalProps { isOpen: boolean; @@ -33,6 +34,7 @@ export const SaveModal: React.FC = ({ initialData, onSaveSuccess, }) => { + const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기 const [formData, setFormData] = useState>(initialData || {}); const [originalData, setOriginalData] = useState>(initialData || {}); const [screenData, setScreenData] = useState(null); @@ -88,13 +90,13 @@ export const SaveModal: React.FC = ({ onClose(); }; - if (typeof window !== 'undefined') { - window.addEventListener('closeSaveModal', handleCloseSaveModal); + if (typeof window !== "undefined") { + window.addEventListener("closeSaveModal", handleCloseSaveModal); } return () => { - if (typeof window !== 'undefined') { - window.removeEventListener('closeSaveModal', handleCloseSaveModal); + if (typeof window !== "undefined") { + window.removeEventListener("closeSaveModal", handleCloseSaveModal); } }; }, [onClose]); @@ -127,16 +129,28 @@ export const SaveModal: React.FC = ({ // 저장할 데이터 준비 const dataToSave = initialData ? changedData : formData; + // 🆕 자동으로 작성자 정보 추가 + const writerValue = user?.userId || userName || "unknown"; + console.log("👤 현재 사용자 정보:", { + userId: user?.userId, + userName: userName, + writerValue: writerValue, + }); + + const dataWithUserInfo = { + ...dataToSave, + writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼 + created_by: writerValue, + updated_by: writerValue, + }; + // 테이블명 결정 - const tableName = - screenData.tableName || - components.find((c) => c.columnName)?.tableName || - "dynamic_form_data"; + const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data"; const saveData: DynamicFormData = { screenId: screenId, tableName: tableName, - data: dataToSave, + data: dataWithUserInfo, }; console.log("💾 저장 요청 데이터:", saveData); @@ -147,10 +161,10 @@ export const SaveModal: React.FC = ({ if (result.success) { // ✅ 저장 성공 toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!"); - + // 모달 닫기 onClose(); - + // 테이블 새로고침 콜백 호출 if (onSaveSuccess) { setTimeout(() => { @@ -187,19 +201,12 @@ export const SaveModal: React.FC = ({ return ( !isSaving && !open && onClose()}> - - + +
- - {initialData ? "데이터 수정" : "데이터 등록"} - + {initialData ? "데이터 수정" : "데이터 등록"}
- -
@@ -227,7 +229,7 @@ export const SaveModal: React.FC = ({
{loading ? (
- +
) : screenData && components.length > 0 ? (
= ({
) : ( -
- 화면에 컴포넌트가 없습니다. -
+
화면에 컴포넌트가 없습니다.
)}
); }; - diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index d669431d..4814933b 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -403,6 +403,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); const isLayoutComponent = targetComponent?.type === "layout"; + // 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용 + const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign"; + + let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만 + + if (isGroupSetting && targetComponent) { + const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig; + const currentGroupId = flowConfig?.groupId; + + if (currentGroupId) { + // 같은 그룹의 모든 버튼 찾기 + affectedComponents = prevLayout.components + .filter((comp) => { + const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig; + return compConfig?.groupId === currentGroupId && compConfig?.enabled; + }) + .map((comp) => comp.id); + + console.log("🔄 그룹 설정 일괄 적용:", { + groupId: currentGroupId, + setting: path.split(".").pop(), + value, + affectedButtons: affectedComponents, + }); + } + } + // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 const positionDelta = { x: 0, y: 0 }; if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { @@ -431,7 +458,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const pathParts = path.split("."); const updatedComponents = prevLayout.components.map((comp) => { - if (comp.id !== componentId) { + // 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용 + const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId; + + if (!shouldUpdate) { // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { // 이 레이아웃의 존에 속한 컴포넌트인지 확인 @@ -3467,10 +3497,45 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 그룹 해제 const ungroupedButtons = ungroupButtons(buttons); - // 레이아웃 업데이트 - const updatedComponents = layout.components.map((comp) => { + // 레이아웃 업데이트 + 플로우 표시 제어 초기화 + const updatedComponents = layout.components.map((comp, index) => { const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id); - return ungrouped || comp; + + if (ungrouped) { + // 원래 위치 복원 또는 현재 위치 유지 + 간격 추가 + const buttonIndex = buttons.findIndex((b) => b.id === comp.id); + const basePosition = comp.position; + + // 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록) + const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격 + + // 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화 + return { + ...ungrouped, + position: { + x: basePosition.x + offsetX, + y: basePosition.y, + z: basePosition.z || 1, + }, + webTypeConfig: { + ...ungrouped.webTypeConfig, + flowVisibilityConfig: { + enabled: false, + targetFlowComponentId: null, + mode: "whitelist", + visibleSteps: [], + hiddenSteps: [], + layoutBehavior: "auto-compact", + groupId: null, + groupDirection: "horizontal", + groupGap: 8, + groupAlign: "start", + }, + }, + }; + } + + return comp; }); const newLayout = { @@ -3481,7 +3546,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setLayout(newLayout); saveToHistory(newLayout); - toast.success(`${buttons.length}개의 버튼 그룹이 해제되었습니다`); + toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`); }, [layout, groupState.selectedComponents, saveToHistory]); // 그룹 생성 (임시 비활성화) diff --git a/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx b/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx index c9ae4e6a..262ee1c9 100644 --- a/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx +++ b/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx @@ -173,6 +173,7 @@ export const FlowVisibilityConfigPanel: React.FC timestamp: new Date().toISOString(), }); + // 현재 버튼에 설정 적용 (그룹 설정은 ScreenDesigner에서 자동으로 일괄 적용됨) onUpdateProperty("webTypeConfig.flowVisibilityConfig", config); }; @@ -235,11 +236,13 @@ export const FlowVisibilityConfigPanel: React.FC return (
-

+

플로우 단계별 표시 설정

-

플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다

+

+ 플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다 +

@@ -253,7 +256,7 @@ export const FlowVisibilityConfigPanel: React.FC setTimeout(() => applyConfig(), 0); }} /> -
@@ -262,7 +265,9 @@ export const FlowVisibilityConfigPanel: React.FC <> {/* 대상 플로우 선택 */}
- + setGroupId(e.target.value)} - placeholder="group-1" - className="h-6 text-xs sm:h-9 sm:text-xs" + {/* 단계 선택 */} +
+
+ +
+
- - {/* 정렬 방향 */} -
- - { - setGroupDirection(value); - setTimeout(() => applyConfig(), 0); - }} > -
- - -
-
- - -
-
-
- - {/* 버튼 간격 */} -
- -
- { - setGroupGap(Number(e.target.value)); - setTimeout(() => applyConfig(), 0); - }} - className="h-6 text-xs sm:h-9 sm:text-xs" - style={{ fontSize: "12px" }} - /> - - {groupGap}px - -
-
- - {/* 정렬 방식 */} -
- - + 모두 해제 + +
- )} - {/* 미리보기 */} - - - - {mode === "whitelist" && visibleSteps.length > 0 && ( -
-

표시 단계:

-
- {visibleSteps.map((stepId) => { - const step = flowSteps.find((s) => s.id === stepId); - return ( - - {step?.stepName || `Step ${stepId}`} - - ); - })} -
-
- )} - {mode === "blacklist" && hiddenSteps.length > 0 && ( -
-

숨김 단계:

-
- {hiddenSteps.map((stepId) => { - const step = flowSteps.find((s) => s.id === stepId); - return ( - - {step?.stepName || `Step ${stepId}`} - - ); - })} -
-
- )} - {mode === "all" &&

이 버튼은 모든 단계에서 표시됩니다.

} - {mode === "whitelist" && visibleSteps.length === 0 &&

표시할 단계를 선택해주세요.

} -
-
+ {/* 스텝 체크박스 목록 */} +
+ {flowSteps.map((step) => { + const isChecked = visibleSteps.includes(step.id); - {/* 🆕 자동 저장 안내 */} - - - - 설정이 자동으로 저장됩니다. 화면 저장 시 함께 적용됩니다. - - + return ( +
+ toggleStep(step.id)} + /> + +
+ ); + })} +
+
+ + {/* 정렬 방식 */} +
+ + +
)} diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 7c4cbf51..c90594bb 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -221,6 +221,12 @@ export const useAuth = () => { setAuthStatus(finalAuthStatus); + console.log("✅ 최종 사용자 상태:", { + userId: userInfo?.userId, + userName: userInfo?.userName, + companyCode: userInfo?.companyCode || userInfo?.company_code, + }); + // 디버깅용 로그 // 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리) @@ -240,8 +246,9 @@ export const useAuth = () => { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser = { - userId: payload.userId || "unknown", - userName: payload.userName || "사용자", + userId: payload.userId || payload.id || "unknown", + userName: payload.userName || payload.name || "사용자", + companyCode: payload.companyCode || payload.company_code || "", isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN", }; @@ -481,6 +488,7 @@ export const useAuth = () => { isAdmin: authStatus.isAdmin, userId: user?.userId, userName: user?.userName, + companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드 // 함수 login, diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index b2c84506..f8b48fdf 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -93,6 +93,9 @@ export interface DynamicComponentRendererProps { // 버튼 액션을 위한 추가 props screenId?: number; tableName?: string; + userId?: string; // 🆕 현재 사용자 ID + userName?: string; // 🆕 현재 사용자 이름 + companyCode?: string; // 🆕 현재 사용자의 회사 코드 onRefresh?: () => void; onClose?: () => void; // 테이블 선택된 행 정보 (다중 선택 액션용) @@ -176,6 +179,9 @@ export const DynamicComponentRenderer: React.FC = onRefresh, onClose, screenId, + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 mode, isInModal, originalData, @@ -196,7 +202,7 @@ export const DynamicComponentRenderer: React.FC = autoGeneration, ...restProps } = props; - + // DOM 안전한 props만 필터링 const safeProps = filterDOMProps(restProps); @@ -229,10 +235,10 @@ export const DynamicComponentRenderer: React.FC = // 렌더러 props 구성 // component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리) const { height: _height, ...styleWithoutHeight } = component.style || {}; - + // 숨김 값 추출 const hiddenValue = component.hidden || component.componentConfig?.hidden; - + const rendererProps = { component, isSelected, @@ -257,6 +263,9 @@ export const DynamicComponentRenderer: React.FC = onRefresh, onClose, screenId, + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 mode, isInModal, readonly: component.readonly, @@ -345,6 +354,9 @@ export const DynamicComponentRenderer: React.FC = onFormDataChange: props.onFormDataChange, screenId: props.screenId, tableName: props.tableName, + userId: props.userId, // 🆕 사용자 ID + userName: props.userName, // 🆕 사용자 이름 + companyCode: props.companyCode, // 🆕 회사 코드 onRefresh: props.onRefresh, onClose: props.onClose, mode: props.mode, diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 0592a514..5f1437ff 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -29,6 +29,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { // 추가 props screenId?: number; tableName?: string; + userId?: string; // 🆕 현재 사용자 ID + userName?: string; // 🆕 현재 사용자 이름 + companyCode?: string; // 🆕 현재 사용자의 회사 코드 onRefresh?: () => void; onClose?: () => void; onFlowRefresh?: () => void; @@ -65,6 +68,9 @@ export const ButtonPrimaryComponent: React.FC = ({ onFormDataChange, screenId, tableName, + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 onRefresh, onClose, onFlowRefresh, @@ -76,6 +82,8 @@ export const ButtonPrimaryComponent: React.FC = ({ }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + // 🔍 디버깅: props 확인 + // 🆕 플로우 단계별 표시 제어 const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); @@ -385,6 +393,9 @@ export const ButtonPrimaryComponent: React.FC = ({ originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 screenId, tableName, + userId, // 🆕 사용자 ID + userName, // 🆕 사용자 이름 + companyCode, // 🆕 회사 코드 onFormDataChange, onRefresh, onClose, diff --git a/frontend/lib/registry/components/date-input/DateInputComponent.tsx b/frontend/lib/registry/components/date-input/DateInputComponent.tsx index 556dd32e..2d5f8071 100644 --- a/frontend/lib/registry/components/date-input/DateInputComponent.tsx +++ b/frontend/lib/registry/components/date-input/DateInputComponent.tsx @@ -45,54 +45,18 @@ export const DateInputComponent: React.FC = ({ // 🎯 자동생성 상태 관리 const [autoGeneratedValue, setAutoGeneratedValue] = useState(""); - // 🚨 컴포넌트 마운트 확인용 로그 - console.log("🚨 DateInputComponent 마운트됨!", { - componentId: component.id, - isInteractive, - isDesignMode, - autoGeneration, - componentAutoGeneration: component.autoGeneration, - externalValue, - formDataValue: formData?.[component.columnName || ""], - timestamp: new Date().toISOString(), - }); - - // 🧪 무조건 실행되는 테스트 - useEffect(() => { - console.log("🧪 DateInputComponent 무조건 실행 테스트!"); - const testDate = "2025-01-19"; // 고정된 테스트 날짜 - setAutoGeneratedValue(testDate); - console.log("🧪 autoGeneratedValue 설정 완료:", testDate); - }, []); // 빈 의존성 배열로 한 번만 실행 - // 자동생성 설정 (props 우선, 컴포넌트 설정 폴백) const finalAutoGeneration = autoGeneration || component.autoGeneration; const finalHidden = hidden !== undefined ? hidden : component.hidden; - // 🧪 테스트용 간단한 자동생성 로직 + // 자동생성 로직 useEffect(() => { - console.log("🔍 DateInputComponent useEffect 실행:", { - componentId: component.id, - finalAutoGeneration, - enabled: finalAutoGeneration?.enabled, - type: finalAutoGeneration?.type, - isInteractive, - isDesignMode, - hasOnFormDataChange: !!onFormDataChange, - columnName: component.columnName, - currentFormValue: formData?.[component.columnName || ""], - }); - - // 🧪 테스트: 자동생성이 활성화되어 있으면 무조건 현재 날짜 설정 if (finalAutoGeneration?.enabled) { const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD - console.log("🧪 테스트용 날짜 생성:", today); - setAutoGeneratedValue(today); // 인터랙티브 모드에서 폼 데이터에도 설정 if (isInteractive && onFormDataChange && component.columnName) { - console.log("📤 테스트용 폼 데이터 업데이트:", component.columnName, today); onFormDataChange(component.columnName, today); } } @@ -167,17 +131,6 @@ export const DateInputComponent: React.FC = ({ rawValue = component.value; } - console.log("🔍 DateInputComponent 값 디버깅:", { - componentId: component.id, - fieldName, - externalValue, - formDataValue: formData?.[component.columnName || ""], - componentValue: component.value, - rawValue, - isInteractive, - hasFormData: !!formData, - }); - // 날짜 형식 변환 함수 (HTML input[type="date"]는 YYYY-MM-DD 형식만 허용) const formatDateForInput = (dateValue: any): string => { if (!dateValue) return ""; diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index 7c963f6e..495d9775 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -51,19 +51,6 @@ export const TextInputComponent: React.FC = ({ // 숨김 상태 (props에서 전달받은 값 우선 사용) const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false; - // 디버깅: 컴포넌트 설정 확인 - console.log("👻 텍스트 입력 컴포넌트 상태:", { - componentId: component.id, - label: component.label, - isHidden, - componentConfig: componentConfig, - readonly: componentConfig.readonly, - disabled: componentConfig.disabled, - required: componentConfig.required, - isDesignMode, - willRender: !(isHidden && !isDesignMode), - }); - // 자동생성된 값 상태 const [autoGeneratedValue, setAutoGeneratedValue] = useState(""); @@ -94,55 +81,27 @@ export const TextInputComponent: React.FC = ({ // 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시) useEffect(() => { - console.log("🔄 자동생성 useEffect 실행:", { - enabled: testAutoGeneration.enabled, - type: testAutoGeneration.type, - isInteractive, - columnName: component.columnName, - hasFormData: !!formData, - hasOnFormDataChange: !!onFormDataChange, - }); - if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") { // 폼 데이터에 이미 값이 있으면 자동생성하지 않음 const currentFormValue = formData?.[component.columnName]; const currentComponentValue = component.value; - console.log("🔍 자동생성 조건 확인:", { - currentFormValue, - currentComponentValue, - hasCurrentValue: !!(currentFormValue || currentComponentValue), - autoGeneratedValue, - }); - // 자동생성된 값이 없고, 현재 값도 없을 때만 생성 if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { const generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName); - console.log("✨ 자동생성된 값:", generatedValue); if (generatedValue) { setAutoGeneratedValue(generatedValue); // 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만) if (isInteractive && onFormDataChange && component.columnName) { - console.log("📝 폼 데이터에 자동생성 값 설정:", { - columnName: component.columnName, - value: generatedValue, - }); onFormDataChange(component.columnName, generatedValue); } } } else if (!autoGeneratedValue && testAutoGeneration.type !== "none") { // 디자인 모드에서도 미리보기용 자동생성 값 표시 const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration); - console.log("🎨 디자인 모드 미리보기 값:", previewValue); setAutoGeneratedValue(previewValue); - } else { - console.log("⏭️ 이미 값이 있어서 자동생성 건너뜀:", { - hasAutoGenerated: !!autoGeneratedValue, - hasFormValue: !!currentFormValue, - hasComponentValue: !!currentComponentValue, - }); } } }, [testAutoGeneration, isInteractive, component.columnName, component.value, formData, onFormDataChange]); @@ -159,11 +118,12 @@ export const TextInputComponent: React.FC = ({ ...component.style, ...style, // 숨김 기능: 편집 모드에서만 연하게 표시 - ...(isHidden && isDesignMode && { - opacity: 0.4, - backgroundColor: "#f3f4f6", - pointerEvents: "auto", - }), + ...(isHidden && + isDesignMode && { + opacity: 0.4, + backgroundColor: "#f3f4f6", + pointerEvents: "auto", + }), }; // 디자인 모드 스타일 @@ -636,18 +596,6 @@ export const TextInputComponent: React.FC = ({ displayValue = typeof rawValue === "object" ? "" : String(rawValue); } - console.log("📄 Input 값 계산:", { - isInteractive, - hasFormData: !!formData, - columnName: component.columnName, - formDataValue: formData?.[component.columnName], - formDataValueType: typeof formData?.[component.columnName], - componentValue: component.value, - autoGeneratedValue, - finalDisplayValue: displayValue, - isObject: typeof displayValue === "object", - }); - return displayValue; })()} placeholder={ diff --git a/frontend/lib/utils/autoGeneration.ts b/frontend/lib/utils/autoGeneration.ts index d7f67f3b..d5f01db8 100644 --- a/frontend/lib/utils/autoGeneration.ts +++ b/frontend/lib/utils/autoGeneration.ts @@ -92,19 +92,19 @@ export class AutoGenerationUtils { * 현재 사용자 ID 가져오기 (실제로는 인증 컨텍스트에서 가져와야 함) */ static getCurrentUserId(): string { - // TODO: 실제 인증 시스템과 연동 + // JWT 토큰에서 사용자 정보 추출 시도 if (typeof window !== "undefined") { - const userInfo = localStorage.getItem("userInfo"); - if (userInfo) { + const token = localStorage.getItem("authToken"); + if (token) { try { - const parsed = JSON.parse(userInfo); - return parsed.userId || parsed.id || "unknown"; + const payload = JSON.parse(atob(token.split(".")[1])); + return payload.userId || payload.id || "unknown"; } catch { - return "unknown"; + // JWT 파싱 실패 시 fallback } } } - return "system"; + return "unknown"; } /** diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index f6205057..16ec7271 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -65,6 +65,9 @@ export interface ButtonActionContext { originalData?: Record; // 부분 업데이트용 원본 데이터 screenId?: number; tableName?: string; + userId?: string; // 🆕 현재 로그인한 사용자 ID + userName?: string; // 🆕 현재 로그인한 사용자 이름 + companyCode?: string; // 🆕 현재 사용자의 회사 코드 onFormDataChange?: (fieldName: string, value: any) => void; onClose?: () => void; onRefresh?: () => void; @@ -207,10 +210,22 @@ export class ButtonActionExecutor { // INSERT 처리 console.log("🆕 INSERT 모드로 저장:", { formData }); + // 🆕 자동으로 작성자 정보 추가 + const writerValue = context.userId || context.userName || "unknown"; + const companyCodeValue = context.companyCode || ""; + + const dataWithUserInfo = { + ...formData, + writer: writerValue, + created_by: writerValue, + updated_by: writerValue, + company_code: companyCodeValue, + }; + saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, - data: formData, + data: dataWithUserInfo, }); } diff --git a/frontend/lib/utils/flowValidation.ts b/frontend/lib/utils/flowValidation.ts index 97e8b0e5..193e9dde 100644 --- a/frontend/lib/utils/flowValidation.ts +++ b/frontend/lib/utils/flowValidation.ts @@ -1,6 +1,6 @@ /** * 노드 플로우 검증 유틸리티 - * + * * 감지 가능한 문제: * 1. 병렬 실행 시 동일 테이블/컬럼 충돌 * 2. WHERE 조건 누락 (전체 테이블 삭제/업데이트) @@ -26,12 +26,12 @@ export type FlowEdge = TypedFlowEdge; /** * 플로우 전체 검증 */ -export function validateFlow( - nodes: FlowNode[], - edges: FlowEdge[] -): FlowValidation[] { +export function validateFlow(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; + // 0. 연결되지 않은 노드 검증 (최우선) + validations.push(...detectDisconnectedNodes(nodes, edges)); + // 1. 병렬 실행 충돌 검증 validations.push(...detectParallelConflicts(nodes, edges)); @@ -47,14 +47,44 @@ export function validateFlow( return validations; } +/** + * 연결되지 않은 노드(고아 노드) 감지 + */ +function detectDisconnectedNodes(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { + const validations: FlowValidation[] = []; + + // 노드가 없으면 검증 스킵 + if (nodes.length === 0) { + return validations; + } + + // 연결된 노드 ID 수집 + const connectedNodeIds = new Set(); + for (const edge of edges) { + connectedNodeIds.add(edge.source); + connectedNodeIds.add(edge.target); + } + + // Comment 노드는 고아 노드여도 괜찮음 (메모 용도) + const disconnectedNodes = nodes.filter((node) => !connectedNodeIds.has(node.id) && node.type !== "comment"); + + // 고아 노드가 있으면 경고 + for (const node of disconnectedNodes) { + validations.push({ + nodeId: node.id, + severity: "warning", + type: "disconnected-node", + message: `"${node.data.displayName || node.type}" 노드가 다른 노드와 연결되어 있지 않습니다. 이 노드는 실행되지 않습니다.`, + }); + } + + return validations; +} + /** * 특정 노드에서 도달 가능한 모든 노드 찾기 (DFS) */ -function getReachableNodes( - startNodeId: string, - allNodes: FlowNode[], - edges: FlowEdge[] -): FlowNode[] { +function getReachableNodes(startNodeId: string, allNodes: FlowNode[], edges: FlowEdge[]): FlowNode[] { const reachable = new Set(); const visited = new Set(); @@ -77,10 +107,7 @@ function getReachableNodes( /** * 병렬 실행 시 동일 테이블/컬럼 충돌 감지 */ -function detectParallelConflicts( - nodes: FlowNode[], - edges: FlowEdge[] -): FlowValidation[] { +function detectParallelConflicts(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 🆕 연결된 노드만 필터링 (고아 노드 제외) @@ -93,41 +120,50 @@ function detectParallelConflicts( // 🆕 소스 노드 찾기 const sourceNodes = nodes.filter( (node) => - (node.type === "tableSource" || - node.type === "externalDBSource" || - node.type === "restAPISource") && - connectedNodeIds.has(node.id) + (node.type === "tableSource" || node.type === "externalDBSource" || node.type === "restAPISource") && + connectedNodeIds.has(node.id), ); // 각 소스 노드에서 시작하는 플로우별로 검증 for (const sourceNode of sourceNodes) { // 이 소스에서 도달 가능한 모든 노드 찾기 const reachableNodes = getReachableNodes(sourceNode.id, nodes, edges); - + // 레벨별로 그룹화 const levels = groupNodesByLevel( reachableNodes, edges.filter( - (e) => - reachableNodes.some((n) => n.id === e.source) && - reachableNodes.some((n) => n.id === e.target) - ) + (e) => reachableNodes.some((n) => n.id === e.source) && reachableNodes.some((n) => n.id === e.target), + ), ); // 각 레벨에서 충돌 검사 for (const [levelNum, levelNodes] of levels.entries()) { - const updateNodes = levelNodes.filter( - (node) => node.type === "updateAction" || node.type === "deleteAction" - ); + const updateNodes = levelNodes.filter((node) => node.type === "updateAction" || node.type === "deleteAction"); if (updateNodes.length < 2) continue; + // 🆕 조건 노드로 분기된 노드들인지 확인 + // 같은 레벨의 노드들이 조건 노드를 통해 분기되었다면 병렬이 아님 + const parentNodes = updateNodes.map((node) => { + const incomingEdge = edges.find((e) => e.target === node.id); + return incomingEdge ? nodes.find((n) => n.id === incomingEdge.source) : null; + }); + + // 모든 부모 노드가 같은 조건 노드라면 병렬이 아닌 조건 분기 + const uniqueParents = new Set(parentNodes.map((p) => p?.id).filter(Boolean)); + const isConditionalBranch = uniqueParents.size === 1 && parentNodes[0]?.type === "condition"; + + if (isConditionalBranch) { + // 조건 분기는 순차 실행이므로 병렬 충돌 검사 스킵 + continue; + } + // 같은 테이블을 수정하는 노드들 찾기 const tableMap = new Map(); for (const node of updateNodes) { - const tableName = - node.data.targetTable || node.data.externalTargetTable; + const tableName = node.data.targetTable || node.data.externalTargetTable; if (tableName) { if (!tableMap.has(tableName)) { tableMap.set(tableName, []); @@ -143,9 +179,7 @@ function detectParallelConflicts( const fieldMap = new Map(); for (const node of conflictNodes) { - const fields = node.data.fieldMappings?.map( - (m: any) => m.targetField - ) || []; + const fields = node.data.fieldMappings?.map((m: any) => m.targetField) || []; for (const field of fields) { if (!fieldMap.has(field)) { fieldMap.set(field, []); @@ -211,10 +245,7 @@ function detectMissingWhereConditions(nodes: FlowNode[]): FlowValidation[] { /** * 순환 참조 감지 (무한 루프) */ -function detectCircularReferences( - nodes: FlowNode[], - edges: FlowEdge[] -): FlowValidation[] { +function detectCircularReferences(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 인접 리스트 생성 @@ -281,10 +312,7 @@ function detectCircularReferences( /** * 데이터 소스 타입 불일치 감지 */ -function detectDataSourceMismatch( - nodes: FlowNode[], - edges: FlowEdge[] -): FlowValidation[] { +function detectDataSourceMismatch(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 각 노드의 데이터 소스 타입 추적 @@ -292,10 +320,7 @@ function detectDataSourceMismatch( // Source 노드들의 타입 수집 for (const node of nodes) { - if ( - node.type === "tableSource" || - node.type === "externalDBSource" - ) { + if (node.type === "tableSource" || node.type === "externalDBSource") { const dataSourceType = node.data.dataSourceType || "context-data"; nodeDataSourceTypes.set(node.id, dataSourceType); } @@ -311,19 +336,13 @@ function detectDataSourceMismatch( // Action 노드들 검사 for (const node of nodes) { - if ( - node.type === "updateAction" || - node.type === "deleteAction" || - node.type === "insertAction" - ) { + if (node.type === "updateAction" || node.type === "deleteAction" || node.type === "insertAction") { const dataSourceType = nodeDataSourceTypes.get(node.id); // table-all 모드인데 WHERE에 특정 레코드 조건이 있는 경우 if (dataSourceType === "table-all") { const whereConditions = node.data.whereConditions || []; - const hasPrimaryKeyCondition = whereConditions.some( - (cond: any) => cond.field === "id" - ); + const hasPrimaryKeyCondition = whereConditions.some((cond: any) => cond.field === "id"); if (hasPrimaryKeyCondition) { validations.push({ @@ -343,10 +362,7 @@ function detectDataSourceMismatch( /** * 레벨별로 노드 그룹화 (위상 정렬) */ -function groupNodesByLevel( - nodes: FlowNode[], - edges: FlowEdge[] -): Map { +function groupNodesByLevel(nodes: FlowNode[], edges: FlowEdge[]): Map { const levels = new Map(); const nodeLevel = new Map(); const inDegree = new Map(); @@ -411,9 +427,7 @@ export function summarizeValidations(validations: FlowValidation[]): { hasBlockingIssues: boolean; } { const errorCount = validations.filter((v) => v.severity === "error").length; - const warningCount = validations.filter( - (v) => v.severity === "warning" - ).length; + const warningCount = validations.filter((v) => v.severity === "warning").length; const infoCount = validations.filter((v) => v.severity === "info").length; return { @@ -427,12 +441,6 @@ export function summarizeValidations(validations: FlowValidation[]): { /** * 특정 노드의 검증 결과 가져오기 */ -export function getNodeValidations( - nodeId: string, - validations: FlowValidation[] -): FlowValidation[] { - return validations.filter( - (v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId) - ); +export function getNodeValidations(nodeId: string, validations: FlowValidation[]): FlowValidation[] { + return validations.filter((v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)); } -