모달창 올리기
This commit is contained in:
parent
eeae338cd4
commit
efdef36cda
|
|
@ -216,7 +216,7 @@ export const deleteFormData = async (
|
|||
): Promise<Response | void> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ export class DataflowControlService {
|
|||
relationshipId: string,
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
sourceData: Record<string, any>,
|
||||
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<string, any>,
|
||||
sourceTable: string
|
||||
sourceTable: string,
|
||||
userId: string = "system"
|
||||
): Promise<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
try {
|
||||
// UPDATE 로직 구현 (향후 확장)
|
||||
// 필드 매핑 적용
|
||||
const mappedData: Record<string, any> = {};
|
||||
|
||||
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<any> {
|
||||
try {
|
||||
// DELETE 로직 구현 (향후 확장)
|
||||
console.log(`⚠️ DELETE 액션은 향후 구현 예정`);
|
||||
console.log(`⚠️ DELETE 액션은 향후 구현 예정 (변경자: ${userId})`);
|
||||
return {
|
||||
success: true,
|
||||
message: "DELETE 액션 실행됨 (향후 구현)",
|
||||
|
|
@ -941,7 +978,9 @@ export class DataflowControlService {
|
|||
sourceData: Record<string, any>
|
||||
): Promise<any> {
|
||||
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
throw new Error(
|
||||
"보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."
|
||||
);
|
||||
|
||||
const results = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>,
|
||||
"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<string, any>,
|
||||
"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<void> {
|
||||
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<string, any>,
|
||||
triggerType: "insert" | "update" | "delete"
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
userId: string = "system"
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||
|
|
@ -1364,7 +1384,8 @@ export class DynamicFormService {
|
|||
relationshipId,
|
||||
triggerType,
|
||||
savedData,
|
||||
tableName
|
||||
tableName,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||||
|
|
|
|||
|
|
@ -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<ScreenDefinition | null>(null);
|
||||
const [layout, setLayout] = useState<LayoutData | null>(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);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const [screenDimensions, setScreenDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
} | null>(null);
|
||||
|
||||
// 폼 데이터 상태 추가
|
||||
|
|
@ -42,11 +44,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ 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<ScreenModalProps> = ({ 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<ScreenModalProps> = ({ 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<ScreenModalProps> = ({ className }) => {
|
|||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{modalState.title}</DialogTitle>
|
||||
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||
{loading && (
|
||||
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -216,35 +234,50 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ 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) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={component}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
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 (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, FlowValidation[]>);
|
||||
},
|
||||
{} as Record<string, FlowValidation[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300">
|
||||
return (
|
||||
<div className="animate-in slide-in-from-right-5 fixed top-4 right-4 z-50 w-80 duration-300">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 bg-white shadow-2xl",
|
||||
summary.hasBlockingIssues
|
||||
? "border-red-500"
|
||||
: summary.warningCount > 0
|
||||
? "border-yellow-500"
|
||||
: "border-blue-500",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 bg-white shadow-2xl",
|
||||
summary.hasBlockingIssues
|
||||
? "border-red-500"
|
||||
: summary.warningCount > 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)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between p-3",
|
||||
summary.hasBlockingIssues
|
||||
? "bg-red-50"
|
||||
: summary.warningCount > 0
|
||||
? "bg-yellow-50"
|
||||
: "bg-blue-50"
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.hasBlockingIssues ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
) : summary.warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.hasBlockingIssues ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
) : summary.warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
플로우 검증
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 text-[10px]">
|
||||
{summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">
|
||||
{summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">플로우 검증</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 text-[10px]">
|
||||
{summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0 hover:bg-white/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">{summary.warningCount}</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확장된 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
<div className="p-2 space-y-2">
|
||||
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
{/* 타입 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{getTypeLabel(type)}
|
||||
<span className="ml-auto">
|
||||
{typeValidations.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검증 항목들 */}
|
||||
<div className="space-y-1 pl-5">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
{validation.message}
|
||||
</p>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-[10px] text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기 →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 메시지 (닫혀있을 때) */}
|
||||
{!isExpanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-gray-600">
|
||||
{summary.hasBlockingIssues
|
||||
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
||||
: summary.warningCount > 0
|
||||
? "⚠️ 경고 사항을 확인하세요"
|
||||
: "ℹ️ 정보를 확인하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0 hover:bg-white/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확장된 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
<div className="space-y-2 p-2">
|
||||
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
{/* 타입 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-blue-100 text-blue-700",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{getTypeLabel(type)}
|
||||
<span className="ml-auto">{typeValidations.length}개</span>
|
||||
</div>
|
||||
|
||||
{/* 검증 항목들 */}
|
||||
<div className="space-y-1 pl-5">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<p className="leading-relaxed text-gray-700">{validation.message}</p>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-[10px] text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기 →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 메시지 (닫혀있을 때) */}
|
||||
{!isExpanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-gray-600">
|
||||
{summary.hasBlockingIssues
|
||||
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
||||
: summary.warningCount > 0
|
||||
? "⚠️ 경고 사항을 확인하세요"
|
||||
: "ℹ️ 정보를 확인하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ValidationNotification.displayName = "ValidationNotification";
|
||||
|
||||
|
|
|
|||
|
|
@ -1359,13 +1359,28 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
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<InteractiveScreenViewerProps> = (
|
|||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto max-h-[60vh] p-2">
|
||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">화면을 불러오는 중...</div>
|
||||
|
|
|
|||
|
|
@ -180,16 +180,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
|
||||
if (comp.type !== "widget") {
|
||||
console.log("🎯 InteractiveScreenViewer - DynamicComponentRenderer 사용:", {
|
||||
componentId: comp.id,
|
||||
componentType: comp.type,
|
||||
isButton: isButtonComponent(comp),
|
||||
componentConfig: comp.componentConfig,
|
||||
style: comp.style,
|
||||
size: comp.size,
|
||||
position: comp.position,
|
||||
});
|
||||
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={comp}
|
||||
|
|
@ -211,7 +201,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 버튼에서 테이블 새로고침 요청됨");
|
||||
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
||||
}}
|
||||
onClose={() => {
|
||||
|
|
|
|||
|
|
@ -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<RealtimePreviewProps> = ({
|
|||
onConfigChange,
|
||||
screenId,
|
||||
tableName,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
flowSelectedData,
|
||||
|
|
@ -291,6 +297,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onConfigChange={onConfigChange}
|
||||
screenId={screenId}
|
||||
tableName={tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
flowSelectedData={flowSelectedData}
|
||||
|
|
|
|||
|
|
@ -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<SaveModalProps> = ({
|
|||
initialData,
|
||||
onSaveSuccess,
|
||||
}) => {
|
||||
const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
|
||||
const [screenData, setScreenData] = useState<any>(null);
|
||||
|
|
@ -88,13 +90,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
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<SaveModalProps> = ({
|
|||
// 저장할 데이터 준비
|
||||
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<SaveModalProps> = ({
|
|||
if (result.success) {
|
||||
// ✅ 저장 성공
|
||||
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
|
||||
|
||||
|
||||
// 모달 닫기
|
||||
onClose();
|
||||
|
||||
|
||||
// 테이블 새로고침 콜백 호출
|
||||
if (onSaveSuccess) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -187,19 +201,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] p-0 gap-0`}>
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
{initialData ? "데이터 수정" : "데이터 등록"}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
|
@ -212,12 +219,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<Button onClick={onClose} disabled={isSaving} variant="ghost" size="sm">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -227,7 +229,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<div className="overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : screenData && components.length > 0 ? (
|
||||
<div
|
||||
|
|
@ -293,13 +295,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
화면에 컴포넌트가 없습니다.
|
||||
</div>
|
||||
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
// 그룹 생성 (임시 비활성화)
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 현재 버튼에 설정 적용 (그룹 설정은 ScreenDesigner에서 자동으로 일괄 적용됨)
|
||||
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
|
||||
};
|
||||
|
||||
|
|
@ -235,11 +236,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium">
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
플로우 단계별 표시 설정
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-xs">플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다</p>
|
||||
<p className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
@ -253,7 +256,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="flow-control-enabled" className="text-sm font-medium">
|
||||
<Label htmlFor="flow-control-enabled" className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
플로우 단계에 따라 버튼 표시 제어
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -262,7 +265,9 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
<>
|
||||
{/* 대상 플로우 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">대상 플로우</Label>
|
||||
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
대상 플로우
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedFlowComponentId || ""}
|
||||
onValueChange={(value) => {
|
||||
|
|
@ -270,7 +275,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs sm:h-10 sm:text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="플로우 위젯 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -278,7 +283,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
const flowConfig = (fw as any).componentConfig || {};
|
||||
const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
|
||||
return (
|
||||
<SelectItem key={fw.id} value={fw.id}>
|
||||
<SelectItem key={fw.id} value={fw.id} style={{ fontSize: "12px" }}>
|
||||
{flowName}
|
||||
</SelectItem>
|
||||
);
|
||||
|
|
@ -290,261 +295,106 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
{/* 플로우가 선택되면 스텝 목록 표시 */}
|
||||
{selectedFlowComponentId && flowSteps.length > 0 && (
|
||||
<>
|
||||
{/* 모드 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">표시 모드</Label>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value: any) => {
|
||||
setMode(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="whitelist" id="mode-whitelist" />
|
||||
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
|
||||
선택한 단계에서만 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="mode-all" />
|
||||
<Label htmlFor="mode-all" className="text-sm font-normal">
|
||||
모든 단계에서 표시
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 단계 선택 (all 모드가 아닐 때만) */}
|
||||
{mode !== "all" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">표시할 단계</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll} className="h-7 px-2 text-xs">
|
||||
모두 선택
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={selectNone} className="h-7 px-2 text-xs">
|
||||
모두 해제
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={invertSelection} className="h-7 px-2 text-xs">
|
||||
반전
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스텝 체크박스 목록 */}
|
||||
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
{flowSteps.map((step) => {
|
||||
const isChecked = visibleSteps.includes(step.id);
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`step-${step.id}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleStep(step.id)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`step-${step.id}`}
|
||||
className="flex flex-1 items-center gap-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Step {step.stepOrder}
|
||||
</Badge>
|
||||
<span>{step.stepName}</span>
|
||||
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이아웃 옵션 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">레이아웃 동작</Label>
|
||||
<RadioGroup
|
||||
value={layoutBehavior}
|
||||
onValueChange={(value: any) => {
|
||||
setLayoutBehavior(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="preserve-position" id="layout-preserve" />
|
||||
<Label htmlFor="layout-preserve" className="text-sm font-normal">
|
||||
원래 위치 유지 (빈 공간 가능)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto-compact" id="layout-compact" />
|
||||
<Label htmlFor="layout-compact" className="text-sm font-normal">
|
||||
자동 정렬 (빈 공간 제거) ⭐ 권장
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
|
||||
{layoutBehavior === "auto-compact" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
그룹 설정
|
||||
</Badge>
|
||||
<p className="text-muted-foreground text-xs">같은 그룹 ID를 가진 버튼들이 자동으로 정렬됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹 ID */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-id" className="text-sm font-medium">
|
||||
그룹 ID
|
||||
</Label>
|
||||
<Input
|
||||
id="group-id"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
placeholder="group-1"
|
||||
className="h-6 text-xs sm:h-9 sm:text-xs"
|
||||
{/* 단계 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
표시할 단계
|
||||
</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
같은 그룹 ID를 가진 버튼들이 하나의 그룹으로 묶입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">정렬 방향</Label>
|
||||
<RadioGroup
|
||||
value={groupDirection}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupDirection(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="horizontal" id="direction-horizontal" />
|
||||
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
가로 정렬
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="vertical" id="direction-vertical" />
|
||||
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
세로 정렬
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 버튼 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-gap" className="text-sm font-medium">
|
||||
버튼 간격 (px)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="group-gap"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={groupGap}
|
||||
onChange={(e) => {
|
||||
setGroupGap(Number(e.target.value));
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
className="h-6 text-xs sm:h-9 sm:text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{groupGap}px
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-align" className="text-sm font-medium">
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select
|
||||
value={groupAlign}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupAlign(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
모두 선택
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectNone}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="group-align"
|
||||
className="h-6 text-xs sm:h-9 sm:text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start">시작점 정렬</SelectItem>
|
||||
<SelectItem value="center">중앙 정렬</SelectItem>
|
||||
<SelectItem value="end">끝점 정렬</SelectItem>
|
||||
<SelectItem value="space-between">양 끝 정렬</SelectItem>
|
||||
<SelectItem value="space-around">균등 배분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
모두 해제
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={invertSelection}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
반전
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{mode === "whitelist" && visibleSteps.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">표시 단계:</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{visibleSteps.map((stepId) => {
|
||||
const step = flowSteps.find((s) => s.id === stepId);
|
||||
return (
|
||||
<Badge key={stepId} variant="secondary" className="text-xs">
|
||||
{step?.stepName || `Step ${stepId}`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === "blacklist" && hiddenSteps.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">숨김 단계:</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{hiddenSteps.map((stepId) => {
|
||||
const step = flowSteps.find((s) => s.id === stepId);
|
||||
return (
|
||||
<Badge key={stepId} variant="destructive" className="text-xs">
|
||||
{step?.stepName || `Step ${stepId}`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === "all" && <p>이 버튼은 모든 단계에서 표시됩니다.</p>}
|
||||
{mode === "whitelist" && visibleSteps.length === 0 && <p>표시할 단계를 선택해주세요.</p>}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/* 스텝 체크박스 목록 */}
|
||||
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
{flowSteps.map((step) => {
|
||||
const isChecked = visibleSteps.includes(step.id);
|
||||
|
||||
{/* 🆕 자동 저장 안내 */}
|
||||
<Alert className="border-green-200 bg-green-50">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-xs text-green-800">
|
||||
설정이 자동으로 저장됩니다. 화면 저장 시 함께 적용됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`step-${step.id}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleStep(step.id)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`step-${step.id}`}
|
||||
className="flex flex-1 items-center gap-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Badge variant="outline" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
Step {step.stepOrder}
|
||||
</Badge>
|
||||
<span>{step.stepName}</span>
|
||||
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-align" className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select
|
||||
value={groupAlign}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupAlign(value);
|
||||
onUpdateProperty("webTypeConfig.flowVisibilityConfig.groupAlign", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="group-align" className="h-6 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start" style={{ fontSize: "12px" }}>
|
||||
시작점 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="center" style={{ fontSize: "12px" }}>
|
||||
중앙 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="end" style={{ fontSize: "12px" }}>
|
||||
끝점 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="space-between" style={{ fontSize: "12px" }}>
|
||||
양 끝 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="space-around" style={{ fontSize: "12px" }}>
|
||||
균등 배분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DynamicComponentRendererProps> =
|
|||
onRefresh,
|
||||
onClose,
|
||||
screenId,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
mode,
|
||||
isInModal,
|
||||
originalData,
|
||||
|
|
@ -196,7 +202,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
autoGeneration,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeProps = filterDOMProps(restProps);
|
||||
|
||||
|
|
@ -229,10 +235,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 렌더러 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<DynamicComponentRendererProps> =
|
|||
onRefresh,
|
||||
onClose,
|
||||
screenId,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
mode,
|
||||
isInModal,
|
||||
readonly: component.readonly,
|
||||
|
|
@ -345,6 +354,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<ButtonPrimaryComponentProps> = ({
|
|||
onFormDataChange,
|
||||
screenId,
|
||||
tableName,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
onRefresh,
|
||||
onClose,
|
||||
onFlowRefresh,
|
||||
|
|
@ -76,6 +82,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}) => {
|
||||
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<ButtonPrimaryComponentProps> = ({
|
|||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
screenId,
|
||||
tableName,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
onFormDataChange,
|
||||
onRefresh,
|
||||
onClose,
|
||||
|
|
|
|||
|
|
@ -45,54 +45,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// 🎯 자동생성 상태 관리
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||
|
||||
// 🚨 컴포넌트 마운트 확인용 로그
|
||||
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<DateInputComponentProps> = ({
|
|||
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 "";
|
||||
|
|
|
|||
|
|
@ -51,19 +51,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// 숨김 상태 (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<string>("");
|
||||
|
||||
|
|
@ -94,55 +81,27 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
|
||||
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
|
||||
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<TextInputComponentProps> = ({
|
|||
...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<TextInputComponentProps> = ({
|
|||
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={
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export interface ButtonActionContext {
|
|||
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
|
|
@ -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<string, FlowNode[]>();
|
||||
|
||||
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<string, FlowNode[]>();
|
||||
|
||||
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<number, FlowNode[]> {
|
||||
function groupNodesByLevel(nodes: FlowNode[], edges: FlowEdge[]): Map<number, FlowNode[]> {
|
||||
const levels = new Map<number, FlowNode[]>();
|
||||
const nodeLevel = new Map<string, number>();
|
||||
const inDegree = new Map<string, number>();
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue