모달창 올리기

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> => { ): Promise<Response | void> => {
try { try {
const { id } = req.params; const { id } = req.params;
const { companyCode } = req.user as any; const { companyCode, userId } = req.user as any;
const { tableName } = req.body; const { tableName } = req.body;
if (!tableName) { 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({ res.json({
success: true, success: true,

View File

@@ -64,7 +64,8 @@ export class DataflowControlService {
relationshipId: string, relationshipId: string,
triggerType: "insert" | "update" | "delete", triggerType: "insert" | "update" | "delete",
sourceData: Record<string, any>, sourceData: Record<string, any>,
tableName: string tableName: string,
userId: string = "system"
): Promise<{ ): Promise<{
success: boolean; success: boolean;
message: string; message: string;
@@ -78,6 +79,7 @@ export class DataflowControlService {
triggerType, triggerType,
sourceData, sourceData,
tableName, tableName,
userId,
}); });
// 관계도 정보 조회 // 관계도 정보 조회
@@ -238,7 +240,8 @@ export class DataflowControlService {
const actionResult = await this.executeMultiConnectionAction( const actionResult = await this.executeMultiConnectionAction(
action, action,
sourceData, sourceData,
targetPlan.sourceTable targetPlan.sourceTable,
userId
); );
executedActions.push({ executedActions.push({
@@ -288,7 +291,8 @@ export class DataflowControlService {
private async executeMultiConnectionAction( private async executeMultiConnectionAction(
action: ControlAction, action: ControlAction,
sourceData: Record<string, any>, sourceData: Record<string, any>,
sourceTable: string sourceTable: string,
userId: string = "system"
): Promise<any> { ): Promise<any> {
try { try {
const extendedAction = action as any; // redesigned UI 구조 접근 const extendedAction = action as any; // redesigned UI 구조 접근
@@ -321,7 +325,8 @@ export class DataflowControlService {
targetTable, targetTable,
fromConnection.id, fromConnection.id,
toConnection.id, toConnection.id,
multiConnService multiConnService,
userId
); );
case "update": case "update":
@@ -332,7 +337,8 @@ export class DataflowControlService {
targetTable, targetTable,
fromConnection.id, fromConnection.id,
toConnection.id, toConnection.id,
multiConnService multiConnService,
userId
); );
case "delete": case "delete":
@@ -343,7 +349,8 @@ export class DataflowControlService {
targetTable, targetTable,
fromConnection.id, fromConnection.id,
toConnection.id, toConnection.id,
multiConnService multiConnService,
userId
); );
default: default:
@@ -368,7 +375,8 @@ export class DataflowControlService {
targetTable: string, targetTable: string,
fromConnectionId: number, fromConnectionId: number,
toConnectionId: number, toConnectionId: number,
multiConnService: any multiConnService: any,
userId: string = "system"
): Promise<any> { ): Promise<any> {
try { 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); console.log(`📋 매핑된 데이터:`, mappedData);
// 대상 연결에 데이터 삽입 // 대상 연결에 데이터 삽입
@@ -421,11 +437,32 @@ export class DataflowControlService {
targetTable: string, targetTable: string,
fromConnectionId: number, fromConnectionId: number,
toConnectionId: number, toConnectionId: number,
multiConnService: any multiConnService: any,
userId: string = "system"
): Promise<any> { ): Promise<any> {
try { 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 액션은 향후 구현 예정`); console.log(`⚠️ UPDATE 액션은 향후 구현 예정`);
return { return {
success: true, success: true,
message: "UPDATE 액션 실행됨 (향후 구현)", message: "UPDATE 액션 실행됨 (향후 구현)",
@@ -449,11 +486,11 @@ export class DataflowControlService {
targetTable: string, targetTable: string,
fromConnectionId: number, fromConnectionId: number,
toConnectionId: number, toConnectionId: number,
multiConnService: any multiConnService: any,
userId: string = "system"
): Promise<any> { ): Promise<any> {
try { try {
// DELETE 로직 구현 (향후 확장) console.log(`⚠️ DELETE 액션은 향후 구현 예정 (변경자: ${userId})`);
console.log(`⚠️ DELETE 액션은 향후 구현 예정`);
return { return {
success: true, success: true,
message: "DELETE 액션 실행됨 (향후 구현)", message: "DELETE 액션 실행됨 (향후 구현)",
@@ -941,7 +978,9 @@ export class DataflowControlService {
sourceData: Record<string, any> sourceData: Record<string, any>
): Promise<any> { ): Promise<any> {
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화 // 보안상 외부 DB에 대한 DELETE 작업은 비활성화
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."); throw new Error(
"보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."
);
const results = []; const results = [];

View File

@@ -220,8 +220,14 @@ export class DynamicFormService {
console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys); console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys);
// 메타데이터 제거 (실제 테이블 컬럼이 아님) // 메타데이터 제거 (실제 테이블 컬럼이 아님)
const { created_by, updated_by, company_code, screen_id, ...actualData } = const {
data; created_by,
updated_by,
writer,
company_code,
screen_id,
...actualData
} = data;
// 기본 데이터 준비 // 기본 데이터 준비
const dataToInsert: any = { ...actualData }; const dataToInsert: any = { ...actualData };
@@ -236,8 +242,17 @@ export class DynamicFormService {
if (tableColumns.includes("regdate") && !dataToInsert.regdate) { if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
dataToInsert.regdate = new Date(); 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")) { if (created_by && tableColumns.includes("created_by")) {
dataToInsert.created_by = created_by; dataToInsert.created_by = created_by;
} }
@@ -579,7 +594,8 @@ export class DynamicFormService {
screenId, screenId,
tableName, tableName,
insertedRecord as Record<string, any>, insertedRecord as Record<string, any>,
"insert" "insert",
created_by || "system"
); );
} catch (controlError) { } catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError); console.error("⚠️ 제어관리 실행 오류:", controlError);
@@ -876,7 +892,8 @@ export class DynamicFormService {
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
tableName, tableName,
updatedRecord as Record<string, any>, updatedRecord as Record<string, any>,
"update" "update",
updated_by || "system"
); );
} catch (controlError) { } catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError); console.error("⚠️ 제어관리 실행 오류:", controlError);
@@ -905,7 +922,8 @@ export class DynamicFormService {
async deleteFormData( async deleteFormData(
id: string | number, id: string | number,
tableName: string, tableName: string,
companyCode?: string companyCode?: string,
userId?: string
): Promise<void> { ): Promise<void> {
try { try {
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
@@ -1010,7 +1028,8 @@ export class DynamicFormService {
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
tableName, tableName,
deletedRecord, deletedRecord,
"delete" "delete",
userId || "system"
); );
} }
} catch (controlError) { } catch (controlError) {
@@ -1315,7 +1334,8 @@ export class DynamicFormService {
screenId: number, screenId: number,
tableName: string, tableName: string,
savedData: Record<string, any>, savedData: Record<string, any>,
triggerType: "insert" | "update" | "delete" triggerType: "insert" | "update" | "delete",
userId: string = "system"
): Promise<void> { ): Promise<void> {
try { try {
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
@@ -1364,7 +1384,8 @@ export class DynamicFormService {
relationshipId, relationshipId,
triggerType, triggerType,
savedData, savedData,
tableName tableName,
userId
); );
console.log(`🎯 제어관리 실행 결과:`, controlResult); console.log(`🎯 제어관리 실행 결과:`, controlResult);

View File

@@ -16,12 +16,16 @@ import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
export default function ScreenViewPage() { export default function ScreenViewPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const screenId = parseInt(params.screenId as string); const screenId = parseInt(params.screenId as string);
// 🆕 현재 로그인한 사용자 정보
const { user, userName, companyCode } = useAuth();
const [screen, setScreen] = useState<ScreenDefinition | null>(null); const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null); const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -270,6 +274,9 @@ export default function ScreenViewPage() {
onClick={() => {}} onClick={() => {}}
screenId={screenId} screenId={screenId}
tableName={screen?.tableName} tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => { onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터:", selectedData); console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
@@ -330,6 +337,9 @@ export default function ScreenViewPage() {
onClick={() => {}} onClick={() => {}}
screenId={screenId} screenId={screenId}
tableName={screen?.tableName} tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => { onSelectedRowsChange={(_, selectedData) => {
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
@@ -434,6 +444,9 @@ export default function ScreenViewPage() {
onDataflowComplete={() => {}} onDataflowComplete={() => {}}
screenId={screenId} screenId={screenId}
tableName={screen?.tableName} tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={(_, selectedData) => { onSelectedRowsChange={(_, selectedData) => {
setSelectedRowsData(selectedData); setSelectedRowsData(selectedData);

View File

@@ -35,6 +35,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const [screenDimensions, setScreenDimensions] = useState<{ const [screenDimensions, setScreenDimensions] = useState<{
width: number; width: number;
height: number; height: number;
offsetX?: number;
offsetY?: number;
} | null>(null); } | null>(null);
// 폼 데이터 상태 추가 // 폼 데이터 상태 추가
@@ -42,11 +44,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면의 실제 크기 계산 함수 // 화면의 실제 크기 계산 함수
const calculateScreenDimensions = (components: ComponentData[]) => { const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {
return {
width: 400,
height: 300,
offsetX: 0,
offsetY: 0,
};
}
// 모든 컴포넌트의 경계 찾기 // 모든 컴포넌트의 경계 찾기
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = 0; let maxX = -Infinity;
let maxY = 0; let maxY = -Infinity;
components.forEach((component) => { components.forEach((component) => {
const x = parseFloat(component.position?.x?.toString() || "0"); 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); maxY = Math.max(maxY, y + height);
}); });
// 컨텐츠 실제 크기 + 넉넉한 여백 (양쪽 각 64px) // 실제 컨텐츠 크기 계산
const contentWidth = maxX - minX; const contentWidth = maxX - minX;
const contentHeight = maxY - minY; 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 { return {
width: Math.min(finalWidth, window.innerWidth * 0.98), width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 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 = () => { const getModalStyle = () => {
if (!screenDimensions) { if (!screenDimensions) {
return { 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: {}, style: {},
}; };
} }
// 헤더 높이만 고려 (패딩 제거) // 헤더 높이를 최소화 (제목 영역만)
const headerHeight = 73; // DialogHeader 실제 높이 (border-b px-6 py-4 포함) const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩)
const totalHeight = screenDimensions.height + headerHeight; const totalHeight = screenDimensions.height + headerHeight;
return { return {
className: "overflow-hidden p-0", className: "overflow-hidden p-0",
style: { style: {
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, // 화면 크기 그대로 width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 헤더 + 화면 높이 height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
maxWidth: "98vw", maxWidth: "98vw",
maxHeight: "95vh", maxHeight: "95vh",
}, },
@@ -197,12 +213,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return ( return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}> <Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}> <DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
<DialogHeader className="border-b px-6 py-4"> <DialogHeader className="shrink-0 border-b px-4 py-3">
<DialogTitle>{modalState.title}</DialogTitle> <DialogTitle className="text-base">{modalState.title}</DialogTitle>
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription> {loading && (
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</DialogHeader> </DialogHeader>
<div className="flex-1 flex items-center justify-center overflow-hidden"> <div className="flex flex-1 items-center justify-center overflow-auto">
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -216,35 +234,50 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
style={{ style={{
width: screenDimensions?.width || 800, width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600, height: screenDimensions?.height || 600,
transformOrigin: 'center center', transformOrigin: "center center",
maxWidth: '100%', maxWidth: "100%",
maxHeight: '100%', maxHeight: "100%",
}} }}
> >
{screenData.components.map((component) => ( {screenData.components.map((component) => {
<InteractiveScreenViewerDynamic // 컴포넌트 위치를 offset만큼 조정 (왼쪽 상단으로 정렬)
key={component.id} const offsetX = screenDimensions?.offsetX || 0;
component={component} const offsetY = screenDimensions?.offsetY || 0;
allComponents={screenData.components}
formData={formData} const adjustedComponent = {
onFormDataChange={(fieldName, value) => { ...component,
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); position: {
console.log("📋 현재 formData:", formData); ...component.position,
setFormData((prev) => { x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
const newFormData = { y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
...prev, },
[fieldName]: value, };
};
console.log("📝 ScreenModal 업데이트된 formData:", newFormData); return (
return newFormData; <InteractiveScreenViewerDynamic
}); key={component.id}
}} component={adjustedComponent}
screenInfo={{ allComponents={screenData.components}
id: modalState.screenId!, formData={formData}
tableName: screenData.screenInfo?.tableName, 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>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <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 { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation"; import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation";
import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps { interface FlowToolbarProps {
validations?: FlowValidation[]; validations?: FlowValidation[];
} }
export function FlowToolbar({ validations = [] }: FlowToolbarProps) { export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow(); const { zoomIn, zoomOut, fitView } = useReactFlow();
const { const {
flowName, flowName,
@@ -56,9 +58,17 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const performSave = async () => { const performSave = async () => {
const result = await saveFlow(); const result = await saveFlow();
if (result.success) { if (result.success) {
alert(`${result.message}\nFlow ID: ${result.flowId}`); toast({
title: "✅ 플로우 저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
variant: "default",
});
} else { } else {
alert(`❌ 저장 실패\n\n${result.message}`); toast({
title: "❌ 저장 실패",
description: result.message,
variant: "destructive",
});
} }
setShowSaveDialog(false); setShowSaveDialog(false);
}; };
@@ -72,18 +82,30 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
a.download = `${flowName || "flow"}.json`; a.download = `${flowName || "flow"}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
alert("✅ JSON 파일로 내보내기 완료!"); toast({
title: "✅ 내보내기 완료",
description: "JSON 파일로 저장되었습니다.",
variant: "default",
});
}; };
const handleDelete = () => { const handleDelete = () => {
if (selectedNodes.length === 0) { if (selectedNodes.length === 0) {
alert("삭제할 노드를 선택해주세요."); toast({
title: "⚠️ 선택된 노드 없음",
description: "삭제할 노드를 선택해주세요.",
variant: "default",
});
return; return;
} }
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) { if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
removeNodes(selectedNodes); removeNodes(selectedNodes);
alert(`${selectedNodes.length}개 노드가 삭제되었습니다.`); toast({
title: "✅ 노드 삭제 완료",
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
variant: "default",
});
} }
}; };

View File

@@ -18,189 +18,178 @@ interface ValidationNotificationProps {
onClose?: () => void; onClose?: () => void;
} }
export const ValidationNotification = memo( export const ValidationNotification = memo(({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
({ validations, onNodeClick, onClose }: ValidationNotificationProps) => { const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const summary = summarizeValidations(validations);
const summary = summarizeValidations(validations);
if (validations.length === 0) { if (validations.length === 0) {
return null; return null;
} }
const getTypeLabel = (type: string): string => { const getTypeLabel = (type: string): string => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
"parallel-conflict": "병렬 실행 충돌", "disconnected-node": "연결되지 않은 노드",
"missing-where": "WHERE 조건 누락", "parallel-conflict": "병렬 실행 충돌",
"circular-reference": "순환 참조", "missing-where": "WHERE 조건 누락",
"data-source-mismatch": "데이터 소스 불일치", "circular-reference": "순환 참조",
"parallel-table-access": "병렬 테이블 접근", "data-source-mismatch": "데이터 소스 불일치",
}; "parallel-table-access": "병렬 테이블 접근",
return labels[type] || type;
}; };
return labels[type] || type;
};
// 타입별로 그룹화 // 타입별로 그룹화
const groupedValidations = validations.reduce((acc, validation) => { const groupedValidations = validations.reduce(
(acc, validation) => {
if (!acc[validation.type]) { if (!acc[validation.type]) {
acc[validation.type] = []; acc[validation.type] = [];
} }
acc[validation.type].push(validation); acc[validation.type].push(validation);
return acc; return acc;
}, {} as Record<string, FlowValidation[]>); },
{} as Record<string, FlowValidation[]>,
);
return ( return (
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300"> <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 <div
className={cn( className={cn(
"rounded-lg border-2 bg-white shadow-2xl", "flex cursor-pointer items-center justify-between p-3",
summary.hasBlockingIssues summary.hasBlockingIssues ? "bg-red-50" : summary.warningCount > 0 ? "bg-yellow-50" : "bg-blue-50",
? "border-red-500"
: summary.warningCount > 0
? "border-yellow-500"
: "border-blue-500"
)} )}
onClick={() => setIsExpanded(!isExpanded)}
> >
{/* 헤더 */} <div className="flex items-center gap-2">
<div {summary.hasBlockingIssues ? (
className={cn( <AlertCircle className="h-5 w-5 text-red-600" />
"flex cursor-pointer items-center justify-between p-3", ) : summary.warningCount > 0 ? (
summary.hasBlockingIssues <AlertTriangle className="h-5 w-5 text-yellow-600" />
? "bg-red-50" ) : (
: summary.warningCount > 0 <Info className="h-5 w-5 text-blue-600" />
? "bg-yellow-50"
: "bg-blue-50"
)} )}
onClick={() => setIsExpanded(!isExpanded)} <span className="text-sm font-semibold text-gray-900"> </span>
>
<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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isExpanded ? ( {summary.errorCount > 0 && (
<ChevronUp className="h-4 w-4 text-gray-400" /> <Badge variant="destructive" className="h-5 text-[10px]">
) : ( {summary.errorCount}
<ChevronDown className="h-4 w-4 text-gray-400" /> </Badge>
)} )}
{onClose && ( {summary.warningCount > 0 && (
<Button <Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">{summary.warningCount}</Badge>
variant="ghost" )}
size="sm" {summary.infoCount > 0 && (
onClick={(e) => { <Badge variant="secondary" className="h-5 text-[10px]">
e.stopPropagation(); {summary.infoCount}
onClose(); </Badge>
}}
className="h-6 w-6 p-0 hover:bg-white/50"
>
<X className="h-3.5 w-3.5" />
</Button>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-1">
{/* 확장된 내용 */} {isExpanded ? (
{isExpanded && ( <ChevronUp className="h-4 w-4 text-gray-400" />
<div className="max-h-[60vh] overflow-y-auto border-t"> ) : (
<div className="p-2 space-y-2"> <ChevronDown className="h-4 w-4 text-gray-400" />
{Object.entries(groupedValidations).map(([type, typeValidations]) => { )}
const firstValidation = typeValidations[0]; {onClose && (
const Icon = <Button
firstValidation.severity === "error" variant="ghost"
? AlertCircle size="sm"
: firstValidation.severity === "warning" onClick={(e) => {
? AlertTriangle e.stopPropagation();
: Info; onClose();
}}
return ( className="h-6 w-6 p-0 hover:bg-white/50"
<div key={type}> >
{/* 타입 헤더 */} <X className="h-3.5 w-3.5" />
<div </Button>
className={cn( )}
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium", </div>
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> </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>
); </div>
} );
); });
ValidationNotification.displayName = "ValidationNotification"; ValidationNotification.displayName = "ValidationNotification";

View File

@@ -1359,13 +1359,28 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
allComponents.find(c => c.columnName)?.tableName || allComponents.find(c => c.columnName)?.tableName ||
"dynamic_form_data"; // 기본값 "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 = { const saveData: DynamicFormData = {
screenId: screenInfo.id, screenId: screenInfo.id,
tableName: tableName, tableName: tableName,
data: mappedData, data: dataWithUserInfo,
}; };
// console.log("🚀 API 저장 요청:", saveData); console.log("🚀 API 저장 요청:", saveData);
const result = await dynamicFormApi.saveFormData(saveData); const result = await dynamicFormApi.saveFormData(saveData);
@@ -1859,12 +1874,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
setPopupScreen(null); setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}> }}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader> <DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle> <DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader> </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 ? ( {popupLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div> <div className="text-gray-500"> ...</div>

View File

@@ -180,16 +180,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용 // 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
if (comp.type !== "widget") { 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 ( return (
<DynamicComponentRenderer <DynamicComponentRenderer
component={comp} component={comp}
@@ -211,7 +201,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
setFlowSelectedStepId(stepId); setFlowSelectedStepId(stepId);
}} }}
onRefresh={() => { onRefresh={() => {
console.log("🔄 버튼에서 테이블 새로고침 요청됨");
// 테이블 컴포넌트는 자체적으로 loadData 호출 // 테이블 컴포넌트는 자체적으로 loadData 호출
}} }}
onClose={() => { onClose={() => {

View File

@@ -38,6 +38,9 @@ interface RealtimePreviewProps {
// 버튼 액션을 위한 props // 버튼 액션을 위한 props
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
selectedRowsData?: any[]; selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
flowSelectedData?: any[]; flowSelectedData?: any[];
@@ -96,6 +99,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onConfigChange, onConfigChange,
screenId, screenId,
tableName, tableName,
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
selectedRowsData, selectedRowsData,
onSelectedRowsChange, onSelectedRowsChange,
flowSelectedData, flowSelectedData,
@@ -291,6 +297,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onConfigChange={onConfigChange} onConfigChange={onConfigChange}
screenId={screenId} screenId={screenId}
tableName={tableName} tableName={tableName}
userId={userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={onSelectedRowsChange} onSelectedRowsChange={onSelectedRowsChange}
flowSelectedData={flowSelectedData} flowSelectedData={flowSelectedData}

View File

@@ -10,6 +10,7 @@ import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { ComponentData } from "@/lib/types/screen"; import { ComponentData } from "@/lib/types/screen";
import { useAuth } from "@/hooks/useAuth";
interface SaveModalProps { interface SaveModalProps {
isOpen: boolean; isOpen: boolean;
@@ -33,6 +34,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
initialData, initialData,
onSaveSuccess, onSaveSuccess,
}) => { }) => {
const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기
const [formData, setFormData] = useState<Record<string, any>>(initialData || {}); const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {}); const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
const [screenData, setScreenData] = useState<any>(null); const [screenData, setScreenData] = useState<any>(null);
@@ -88,13 +90,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
onClose(); onClose();
}; };
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.addEventListener('closeSaveModal', handleCloseSaveModal); window.addEventListener("closeSaveModal", handleCloseSaveModal);
} }
return () => { return () => {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.removeEventListener('closeSaveModal', handleCloseSaveModal); window.removeEventListener("closeSaveModal", handleCloseSaveModal);
} }
}; };
}, [onClose]); }, [onClose]);
@@ -127,16 +129,28 @@ export const SaveModal: React.FC<SaveModalProps> = ({
// 저장할 데이터 준비 // 저장할 데이터 준비
const dataToSave = initialData ? changedData : formData; 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 = const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data";
screenData.tableName ||
components.find((c) => c.columnName)?.tableName ||
"dynamic_form_data";
const saveData: DynamicFormData = { const saveData: DynamicFormData = {
screenId: screenId, screenId: screenId,
tableName: tableName, tableName: tableName,
data: dataToSave, data: dataWithUserInfo,
}; };
console.log("💾 저장 요청 데이터:", saveData); console.log("💾 저장 요청 데이터:", saveData);
@@ -147,10 +161,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
if (result.success) { if (result.success) {
// ✅ 저장 성공 // ✅ 저장 성공
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!"); toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
// 모달 닫기 // 모달 닫기
onClose(); onClose();
// 테이블 새로고침 콜백 호출 // 테이블 새로고침 콜백 호출
if (onSaveSuccess) { if (onSaveSuccess) {
setTimeout(() => { setTimeout(() => {
@@ -187,19 +201,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] p-0 gap-0`}> <DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
<DialogHeader className="px-6 py-4 border-b"> <DialogHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold"> <DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
{initialData ? "데이터 수정" : "데이터 등록"}
</DialogTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
onClick={handleSave}
disabled={isSaving}
size="sm"
className="gap-2"
>
{isSaving ? ( {isSaving ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -212,12 +219,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
</> </>
)} )}
</Button> </Button>
<Button <Button onClick={onClose} disabled={isSaving} variant="ghost" size="sm">
onClick={onClose}
disabled={isSaving}
variant="ghost"
size="sm"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -227,7 +229,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
<div className="overflow-auto p-6"> <div className="overflow-auto p-6">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <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> </div>
) : screenData && components.length > 0 ? ( ) : screenData && components.length > 0 ? (
<div <div
@@ -293,13 +295,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
</div> </div>
</div> </div>
) : ( ) : (
<div className="py-12 text-center text-muted-foreground"> <div className="text-muted-foreground py-12 text-center"> .</div>
.
</div>
)} )}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View File

@@ -403,6 +403,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); const targetComponent = prevLayout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout"; 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 }; const positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { 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 pathParts = path.split(".");
const updatedComponents = prevLayout.components.map((comp) => { 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)) { if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) {
// 이 레이아웃의 존에 속한 컴포넌트인지 확인 // 이 레이아웃의 존에 속한 컴포넌트인지 확인
@@ -3467,10 +3497,45 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 그룹 해제 // 그룹 해제
const ungroupedButtons = ungroupButtons(buttons); 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); 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 = { const newLayout = {
@@ -3481,7 +3546,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(newLayout); setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
toast.success(`${buttons.length}개의 버튼 그룹이 해제되었습니다`); toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`);
}, [layout, groupState.selectedComponents, saveToHistory]); }, [layout, groupState.selectedComponents, saveToHistory]);
// 그룹 생성 (임시 비활성화) // 그룹 생성 (임시 비활성화)

View File

@@ -173,6 +173,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
// 현재 버튼에 설정 적용 (그룹 설정은 ScreenDesigner에서 자동으로 일괄 적용됨)
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config); onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
}; };
@@ -235,11 +236,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <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" /> <Workflow className="h-4 w-4" />
</h4> </h4>
<p className="text-muted-foreground text-xs"> </p> <p className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -253,7 +256,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
setTimeout(() => applyConfig(), 0); 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> </Label>
</div> </div>
@@ -262,7 +265,9 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
<> <>
{/* 대상 플로우 선택 */} {/* 대상 플로우 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> </Label> <Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
</Label>
<Select <Select
value={selectedFlowComponentId || ""} value={selectedFlowComponentId || ""}
onValueChange={(value) => { onValueChange={(value) => {
@@ -270,7 +275,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
setTimeout(() => applyConfig(), 0); 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="플로우 위젯 선택" /> <SelectValue placeholder="플로우 위젯 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -278,7 +283,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
const flowConfig = (fw as any).componentConfig || {}; const flowConfig = (fw as any).componentConfig || {};
const flowName = flowConfig.flowName || `플로우 ${fw.id}`; const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
return ( return (
<SelectItem key={fw.id} value={fw.id}> <SelectItem key={fw.id} value={fw.id} style={{ fontSize: "12px" }}>
{flowName} {flowName}
</SelectItem> </SelectItem>
); );
@@ -290,261 +295,106 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 플로우가 선택되면 스텝 목록 표시 */} {/* 플로우가 선택되면 스텝 목록 표시 */}
{selectedFlowComponentId && flowSteps.length > 0 && ( {selectedFlowComponentId && flowSteps.length > 0 && (
<> <>
{/* 모드 선택 */} {/* 단계 선택 */}
<div className="space-y-2"> <div className="space-y-3">
<Label className="text-sm font-medium"> </Label> <div className="flex items-center justify-between">
<RadioGroup <Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
value={mode}
onValueChange={(value: any) => { </Label>
setMode(value); <div className="flex gap-1">
setTimeout(() => applyConfig(), 0); <Button
}} variant="ghost"
> size="sm"
<div className="flex items-center space-x-2"> onClick={selectAll}
<RadioGroupItem value="whitelist" id="mode-whitelist" /> className="h-7 px-2 text-xs"
<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"
style={{ fontSize: "12px" }} 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" /> </Button>
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal"> <Button
<ArrowRight className="h-4 w-4" /> variant="ghost"
size="sm"
</Label> onClick={selectNone}
</div> className="h-7 px-2 text-xs"
<div className="flex items-center space-x-2"> style={{ fontSize: "12px" }}
<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);
}}
> >
<SelectTrigger
id="group-align" </Button>
className="h-6 text-xs sm:h-9 sm:text-xs" <Button
style={{ fontSize: "12px" }} variant="ghost"
> size="sm"
<SelectValue /> onClick={invertSelection}
</SelectTrigger> className="h-7 px-2 text-xs"
<SelectContent> style={{ fontSize: "12px" }}
<SelectItem value="start"> </SelectItem> >
<SelectItem value="center"> </SelectItem>
<SelectItem value="end"> </SelectItem> </Button>
<SelectItem value="space-between"> </SelectItem>
<SelectItem value="space-around"> </SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
)}
{/* 미리보기 */} {/* 스텝 체크박스 목록 */}
<Alert> <div className="bg-muted/30 space-y-2 rounded-lg border p-3">
<Info className="h-4 w-4" /> {flowSteps.map((step) => {
<AlertDescription className="text-xs"> const isChecked = visibleSteps.includes(step.id);
{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>
{/* 🆕 자동 저장 안내 */} return (
<Alert className="border-green-200 bg-green-50"> <div key={step.id} className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600" /> <Checkbox
<AlertDescription className="text-xs text-green-800"> id={`step-${step.id}`}
. . checked={isChecked}
</AlertDescription> onCheckedChange={() => toggleStep(step.id)}
</Alert> />
<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); setAuthStatus(finalAuthStatus);
console.log("✅ 최종 사용자 상태:", {
userId: userInfo?.userId,
userName: userInfo?.userName,
companyCode: userInfo?.companyCode || userInfo?.company_code,
});
// 디버깅용 로그 // 디버깅용 로그
// 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리) // 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리)
@@ -240,8 +246,9 @@ export const useAuth = () => {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser = { const tempUser = {
userId: payload.userId || "unknown", userId: payload.userId || payload.id || "unknown",
userName: payload.userName || "사용자", userName: payload.userName || payload.name || "사용자",
companyCode: payload.companyCode || payload.company_code || "",
isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN", isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN",
}; };
@@ -481,6 +488,7 @@ export const useAuth = () => {
isAdmin: authStatus.isAdmin, isAdmin: authStatus.isAdmin,
userId: user?.userId, userId: user?.userId,
userName: user?.userName, userName: user?.userName,
companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드
// 함수 // 함수
login, login,

View File

@@ -93,6 +93,9 @@ export interface DynamicComponentRendererProps {
// 버튼 액션을 위한 추가 props // 버튼 액션을 위한 추가 props
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
onRefresh?: () => void; onRefresh?: () => void;
onClose?: () => void; onClose?: () => void;
// 테이블 선택된 행 정보 (다중 선택 액션용) // 테이블 선택된 행 정보 (다중 선택 액션용)
@@ -176,6 +179,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onRefresh, onRefresh,
onClose, onClose,
screenId, screenId,
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
mode, mode,
isInModal, isInModal,
originalData, originalData,
@@ -196,7 +202,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
autoGeneration, autoGeneration,
...restProps ...restProps
} = props; } = props;
// DOM 안전한 props만 필터링 // DOM 안전한 props만 필터링
const safeProps = filterDOMProps(restProps); const safeProps = filterDOMProps(restProps);
@@ -229,10 +235,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 렌더러 props 구성 // 렌더러 props 구성
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리) // component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
const { height: _height, ...styleWithoutHeight } = component.style || {}; const { height: _height, ...styleWithoutHeight } = component.style || {};
// 숨김 값 추출 // 숨김 값 추출
const hiddenValue = component.hidden || component.componentConfig?.hidden; const hiddenValue = component.hidden || component.componentConfig?.hidden;
const rendererProps = { const rendererProps = {
component, component,
isSelected, isSelected,
@@ -257,6 +263,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onRefresh, onRefresh,
onClose, onClose,
screenId, screenId,
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
mode, mode,
isInModal, isInModal,
readonly: component.readonly, readonly: component.readonly,
@@ -345,6 +354,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onFormDataChange: props.onFormDataChange, onFormDataChange: props.onFormDataChange,
screenId: props.screenId, screenId: props.screenId,
tableName: props.tableName, tableName: props.tableName,
userId: props.userId, // 🆕 사용자 ID
userName: props.userName, // 🆕 사용자 이름
companyCode: props.companyCode, // 🆕 회사 코드
onRefresh: props.onRefresh, onRefresh: props.onRefresh,
onClose: props.onClose, onClose: props.onClose,
mode: props.mode, mode: props.mode,

View File

@@ -29,6 +29,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
onRefresh?: () => void; onRefresh?: () => void;
onClose?: () => void; onClose?: () => void;
onFlowRefresh?: () => void; onFlowRefresh?: () => void;
@@ -65,6 +68,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
onFormDataChange, onFormDataChange,
screenId, screenId,
tableName, tableName,
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
onRefresh, onRefresh,
onClose, onClose,
onFlowRefresh, onFlowRefresh,
@@ -76,6 +82,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
// 🔍 디버깅: props 확인
// 🆕 플로우 단계별 표시 제어 // 🆕 플로우 단계별 표시 제어
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);
@@ -385,6 +393,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
screenId, screenId,
tableName, tableName,
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
onFormDataChange, onFormDataChange,
onRefresh, onRefresh,
onClose, onClose,

View File

@@ -45,54 +45,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
// 🎯 자동생성 상태 관리 // 🎯 자동생성 상태 관리
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>(""); 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 우선, 컴포넌트 설정 폴백) // 자동생성 설정 (props 우선, 컴포넌트 설정 폴백)
const finalAutoGeneration = autoGeneration || component.autoGeneration; const finalAutoGeneration = autoGeneration || component.autoGeneration;
const finalHidden = hidden !== undefined ? hidden : component.hidden; const finalHidden = hidden !== undefined ? hidden : component.hidden;
// 🧪 테스트용 간단한 자동생성 로직 // 자동생성 로직
useEffect(() => { 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) { if (finalAutoGeneration?.enabled) {
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
console.log("🧪 테스트용 날짜 생성:", today);
setAutoGeneratedValue(today); setAutoGeneratedValue(today);
// 인터랙티브 모드에서 폼 데이터에도 설정 // 인터랙티브 모드에서 폼 데이터에도 설정
if (isInteractive && onFormDataChange && component.columnName) { if (isInteractive && onFormDataChange && component.columnName) {
console.log("📤 테스트용 폼 데이터 업데이트:", component.columnName, today);
onFormDataChange(component.columnName, today); onFormDataChange(component.columnName, today);
} }
} }
@@ -167,17 +131,6 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
rawValue = component.value; 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 형식만 허용) // 날짜 형식 변환 함수 (HTML input[type="date"]는 YYYY-MM-DD 형식만 허용)
const formatDateForInput = (dateValue: any): string => { const formatDateForInput = (dateValue: any): string => {
if (!dateValue) return ""; if (!dateValue) return "";

View File

@@ -51,19 +51,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 숨김 상태 (props에서 전달받은 값 우선 사용) // 숨김 상태 (props에서 전달받은 값 우선 사용)
const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false; 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>(""); const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
@@ -94,55 +81,27 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시) // 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
useEffect(() => { useEffect(() => {
console.log("🔄 자동생성 useEffect 실행:", {
enabled: testAutoGeneration.enabled,
type: testAutoGeneration.type,
isInteractive,
columnName: component.columnName,
hasFormData: !!formData,
hasOnFormDataChange: !!onFormDataChange,
});
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") { if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음 // 폼 데이터에 이미 값이 있으면 자동생성하지 않음
const currentFormValue = formData?.[component.columnName]; const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value; const currentComponentValue = component.value;
console.log("🔍 자동생성 조건 확인:", {
currentFormValue,
currentComponentValue,
hasCurrentValue: !!(currentFormValue || currentComponentValue),
autoGeneratedValue,
});
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성 // 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
const generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName); const generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
console.log("✨ 자동생성된 값:", generatedValue);
if (generatedValue) { if (generatedValue) {
setAutoGeneratedValue(generatedValue); setAutoGeneratedValue(generatedValue);
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만) // 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
if (isInteractive && onFormDataChange && component.columnName) { if (isInteractive && onFormDataChange && component.columnName) {
console.log("📝 폼 데이터에 자동생성 값 설정:", {
columnName: component.columnName,
value: generatedValue,
});
onFormDataChange(component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue);
} }
} }
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") { } else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {
// 디자인 모드에서도 미리보기용 자동생성 값 표시 // 디자인 모드에서도 미리보기용 자동생성 값 표시
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration); const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
console.log("🎨 디자인 모드 미리보기 값:", previewValue);
setAutoGeneratedValue(previewValue); setAutoGeneratedValue(previewValue);
} else {
console.log("⏭️ 이미 값이 있어서 자동생성 건너뜀:", {
hasAutoGenerated: !!autoGeneratedValue,
hasFormValue: !!currentFormValue,
hasComponentValue: !!currentComponentValue,
});
} }
} }
}, [testAutoGeneration, isInteractive, component.columnName, component.value, formData, onFormDataChange]); }, [testAutoGeneration, isInteractive, component.columnName, component.value, formData, onFormDataChange]);
@@ -159,11 +118,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
...component.style, ...component.style,
...style, ...style,
// 숨김 기능: 편집 모드에서만 연하게 표시 // 숨김 기능: 편집 모드에서만 연하게 표시
...(isHidden && isDesignMode && { ...(isHidden &&
opacity: 0.4, isDesignMode && {
backgroundColor: "#f3f4f6", opacity: 0.4,
pointerEvents: "auto", backgroundColor: "#f3f4f6",
}), pointerEvents: "auto",
}),
}; };
// 디자인 모드 스타일 // 디자인 모드 스타일
@@ -636,18 +596,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
displayValue = typeof rawValue === "object" ? "" : String(rawValue); 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; return displayValue;
})()} })()}
placeholder={ placeholder={

View File

@@ -92,19 +92,19 @@ export class AutoGenerationUtils {
* 현재 사용자 ID 가져오기 (실제로는 인증 컨텍스트에서 가져와야 함) * 현재 사용자 ID 가져오기 (실제로는 인증 컨텍스트에서 가져와야 함)
*/ */
static getCurrentUserId(): string { static getCurrentUserId(): string {
// TODO: 실제 인증 시스템과 연동 // JWT 토큰에서 사용자 정보 추출 시도
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const userInfo = localStorage.getItem("userInfo"); const token = localStorage.getItem("authToken");
if (userInfo) { if (token) {
try { try {
const parsed = JSON.parse(userInfo); const payload = JSON.parse(atob(token.split(".")[1]));
return parsed.userId || parsed.id || "unknown"; return payload.userId || payload.id || "unknown";
} catch { } catch {
return "unknown"; // JWT 파싱 실패 시 fallback
} }
} }
} }
return "system"; return "unknown";
} }
/** /**

View File

@@ -65,6 +65,9 @@ export interface ButtonActionContext {
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터 originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
userId?: string; // 🆕 현재 로그인한 사용자 ID
userName?: string; // 🆕 현재 로그인한 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
onFormDataChange?: (fieldName: string, value: any) => void; onFormDataChange?: (fieldName: string, value: any) => void;
onClose?: () => void; onClose?: () => void;
onRefresh?: () => void; onRefresh?: () => void;
@@ -207,10 +210,22 @@ export class ButtonActionExecutor {
// INSERT 처리 // INSERT 처리
console.log("🆕 INSERT 모드로 저장:", { formData }); 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({ saveResult = await DynamicFormApi.saveFormData({
screenId, screenId,
tableName, tableName,
data: formData, data: dataWithUserInfo,
}); });
} }

View File

@@ -1,6 +1,6 @@
/** /**
* 노드 플로우 검증 유틸리티 * 노드 플로우 검증 유틸리티
* *
* 감지 가능한 문제: * 감지 가능한 문제:
* 1. 병렬 실행 시 동일 테이블/컬럼 충돌 * 1. 병렬 실행 시 동일 테이블/컬럼 충돌
* 2. WHERE 조건 누락 (전체 테이블 삭제/업데이트) * 2. WHERE 조건 누락 (전체 테이블 삭제/업데이트)
@@ -26,12 +26,12 @@ export type FlowEdge = TypedFlowEdge;
/** /**
* 플로우 전체 검증 * 플로우 전체 검증
*/ */
export function validateFlow( export function validateFlow(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] {
nodes: FlowNode[],
edges: FlowEdge[]
): FlowValidation[] {
const validations: FlowValidation[] = []; const validations: FlowValidation[] = [];
// 0. 연결되지 않은 노드 검증 (최우선)
validations.push(...detectDisconnectedNodes(nodes, edges));
// 1. 병렬 실행 충돌 검증 // 1. 병렬 실행 충돌 검증
validations.push(...detectParallelConflicts(nodes, edges)); validations.push(...detectParallelConflicts(nodes, edges));
@@ -47,14 +47,44 @@ export function validateFlow(
return validations; 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) * 특정 노드에서 도달 가능한 모든 노드 찾기 (DFS)
*/ */
function getReachableNodes( function getReachableNodes(startNodeId: string, allNodes: FlowNode[], edges: FlowEdge[]): FlowNode[] {
startNodeId: string,
allNodes: FlowNode[],
edges: FlowEdge[]
): FlowNode[] {
const reachable = new Set<string>(); const reachable = new Set<string>();
const visited = new Set<string>(); const visited = new Set<string>();
@@ -77,10 +107,7 @@ function getReachableNodes(
/** /**
* 병렬 실행 시 동일 테이블/컬럼 충돌 감지 * 병렬 실행 시 동일 테이블/컬럼 충돌 감지
*/ */
function detectParallelConflicts( function detectParallelConflicts(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] {
nodes: FlowNode[],
edges: FlowEdge[]
): FlowValidation[] {
const validations: FlowValidation[] = []; const validations: FlowValidation[] = [];
// 🆕 연결된 노드만 필터링 (고아 노드 제외) // 🆕 연결된 노드만 필터링 (고아 노드 제외)
@@ -93,41 +120,50 @@ function detectParallelConflicts(
// 🆕 소스 노드 찾기 // 🆕 소스 노드 찾기
const sourceNodes = nodes.filter( const sourceNodes = nodes.filter(
(node) => (node) =>
(node.type === "tableSource" || (node.type === "tableSource" || node.type === "externalDBSource" || node.type === "restAPISource") &&
node.type === "externalDBSource" || connectedNodeIds.has(node.id),
node.type === "restAPISource") &&
connectedNodeIds.has(node.id)
); );
// 각 소스 노드에서 시작하는 플로우별로 검증 // 각 소스 노드에서 시작하는 플로우별로 검증
for (const sourceNode of sourceNodes) { for (const sourceNode of sourceNodes) {
// 이 소스에서 도달 가능한 모든 노드 찾기 // 이 소스에서 도달 가능한 모든 노드 찾기
const reachableNodes = getReachableNodes(sourceNode.id, nodes, edges); const reachableNodes = getReachableNodes(sourceNode.id, nodes, edges);
// 레벨별로 그룹화 // 레벨별로 그룹화
const levels = groupNodesByLevel( const levels = groupNodesByLevel(
reachableNodes, reachableNodes,
edges.filter( edges.filter(
(e) => (e) => reachableNodes.some((n) => n.id === e.source) && reachableNodes.some((n) => n.id === e.target),
reachableNodes.some((n) => n.id === e.source) && ),
reachableNodes.some((n) => n.id === e.target)
)
); );
// 각 레벨에서 충돌 검사 // 각 레벨에서 충돌 검사
for (const [levelNum, levelNodes] of levels.entries()) { for (const [levelNum, levelNodes] of levels.entries()) {
const updateNodes = levelNodes.filter( const updateNodes = levelNodes.filter((node) => node.type === "updateAction" || node.type === "deleteAction");
(node) => node.type === "updateAction" || node.type === "deleteAction"
);
if (updateNodes.length < 2) continue; 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[]>(); const tableMap = new Map<string, FlowNode[]>();
for (const node of updateNodes) { for (const node of updateNodes) {
const tableName = const tableName = node.data.targetTable || node.data.externalTargetTable;
node.data.targetTable || node.data.externalTargetTable;
if (tableName) { if (tableName) {
if (!tableMap.has(tableName)) { if (!tableMap.has(tableName)) {
tableMap.set(tableName, []); tableMap.set(tableName, []);
@@ -143,9 +179,7 @@ function detectParallelConflicts(
const fieldMap = new Map<string, FlowNode[]>(); const fieldMap = new Map<string, FlowNode[]>();
for (const node of conflictNodes) { for (const node of conflictNodes) {
const fields = node.data.fieldMappings?.map( const fields = node.data.fieldMappings?.map((m: any) => m.targetField) || [];
(m: any) => m.targetField
) || [];
for (const field of fields) { for (const field of fields) {
if (!fieldMap.has(field)) { if (!fieldMap.has(field)) {
fieldMap.set(field, []); fieldMap.set(field, []);
@@ -211,10 +245,7 @@ function detectMissingWhereConditions(nodes: FlowNode[]): FlowValidation[] {
/** /**
* 순환 참조 감지 (무한 루프) * 순환 참조 감지 (무한 루프)
*/ */
function detectCircularReferences( function detectCircularReferences(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] {
nodes: FlowNode[],
edges: FlowEdge[]
): FlowValidation[] {
const validations: FlowValidation[] = []; const validations: FlowValidation[] = [];
// 인접 리스트 생성 // 인접 리스트 생성
@@ -281,10 +312,7 @@ function detectCircularReferences(
/** /**
* 데이터 소스 타입 불일치 감지 * 데이터 소스 타입 불일치 감지
*/ */
function detectDataSourceMismatch( function detectDataSourceMismatch(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] {
nodes: FlowNode[],
edges: FlowEdge[]
): FlowValidation[] {
const validations: FlowValidation[] = []; const validations: FlowValidation[] = [];
// 각 노드의 데이터 소스 타입 추적 // 각 노드의 데이터 소스 타입 추적
@@ -292,10 +320,7 @@ function detectDataSourceMismatch(
// Source 노드들의 타입 수집 // Source 노드들의 타입 수집
for (const node of nodes) { for (const node of nodes) {
if ( if (node.type === "tableSource" || node.type === "externalDBSource") {
node.type === "tableSource" ||
node.type === "externalDBSource"
) {
const dataSourceType = node.data.dataSourceType || "context-data"; const dataSourceType = node.data.dataSourceType || "context-data";
nodeDataSourceTypes.set(node.id, dataSourceType); nodeDataSourceTypes.set(node.id, dataSourceType);
} }
@@ -311,19 +336,13 @@ function detectDataSourceMismatch(
// Action 노드들 검사 // Action 노드들 검사
for (const node of nodes) { for (const node of nodes) {
if ( if (node.type === "updateAction" || node.type === "deleteAction" || node.type === "insertAction") {
node.type === "updateAction" ||
node.type === "deleteAction" ||
node.type === "insertAction"
) {
const dataSourceType = nodeDataSourceTypes.get(node.id); const dataSourceType = nodeDataSourceTypes.get(node.id);
// table-all 모드인데 WHERE에 특정 레코드 조건이 있는 경우 // table-all 모드인데 WHERE에 특정 레코드 조건이 있는 경우
if (dataSourceType === "table-all") { if (dataSourceType === "table-all") {
const whereConditions = node.data.whereConditions || []; const whereConditions = node.data.whereConditions || [];
const hasPrimaryKeyCondition = whereConditions.some( const hasPrimaryKeyCondition = whereConditions.some((cond: any) => cond.field === "id");
(cond: any) => cond.field === "id"
);
if (hasPrimaryKeyCondition) { if (hasPrimaryKeyCondition) {
validations.push({ validations.push({
@@ -343,10 +362,7 @@ function detectDataSourceMismatch(
/** /**
* 레벨별로 노드 그룹화 (위상 정렬) * 레벨별로 노드 그룹화 (위상 정렬)
*/ */
function groupNodesByLevel( function groupNodesByLevel(nodes: FlowNode[], edges: FlowEdge[]): Map<number, FlowNode[]> {
nodes: FlowNode[],
edges: FlowEdge[]
): Map<number, FlowNode[]> {
const levels = new Map<number, FlowNode[]>(); const levels = new Map<number, FlowNode[]>();
const nodeLevel = new Map<string, number>(); const nodeLevel = new Map<string, number>();
const inDegree = new Map<string, number>(); const inDegree = new Map<string, number>();
@@ -411,9 +427,7 @@ export function summarizeValidations(validations: FlowValidation[]): {
hasBlockingIssues: boolean; hasBlockingIssues: boolean;
} { } {
const errorCount = validations.filter((v) => v.severity === "error").length; const errorCount = validations.filter((v) => v.severity === "error").length;
const warningCount = validations.filter( const warningCount = validations.filter((v) => v.severity === "warning").length;
(v) => v.severity === "warning"
).length;
const infoCount = validations.filter((v) => v.severity === "info").length; const infoCount = validations.filter((v) => v.severity === "info").length;
return { return {
@@ -427,12 +441,6 @@ export function summarizeValidations(validations: FlowValidation[]): {
/** /**
* 특정 노드의 검증 결과 가져오기 * 특정 노드의 검증 결과 가져오기
*/ */
export function getNodeValidations( export function getNodeValidations(nodeId: string, validations: FlowValidation[]): FlowValidation[] {
nodeId: string, return validations.filter((v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId));
validations: FlowValidation[]
): FlowValidation[] {
return validations.filter(
(v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)
);
} }