모달창 올리기

This commit is contained in:
kjs 2025-10-29 11:26:00 +09:00
parent eeae338cd4
commit efdef36cda
21 changed files with 727 additions and 728 deletions

View File

@ -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,

View File

@ -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 = [];

View File

@ -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);

View File

@ -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);

View File

@ -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">

View File

@ -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",
});
}
};

View File

@ -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";

View File

@ -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>

View File

@ -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={() => {

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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]);
// 그룹 생성 (임시 비활성화)

View File

@ -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>
</>
)}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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 "";

View File

@ -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={

View File

@ -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";
}
/**

View File

@ -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,
});
}

View File

@ -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));
}