분할 패널 및 반복 필드 그룹 컴포넌트
This commit is contained in:
parent
716cfcb2cf
commit
a0dde51109
|
|
@ -112,9 +112,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
|
|||
json: "textarea",
|
||||
jsonb: "textarea",
|
||||
|
||||
// 배열 타입 (텍스트로 처리)
|
||||
ARRAY: "textarea",
|
||||
|
||||
// UUID 타입
|
||||
uuid: "text",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 제어 시스템 페이지
|
||||
* 제어 시스템 페이지 (리다이렉트)
|
||||
* 이 페이지는 /admin/dataflow로 리다이렉트됩니다.
|
||||
*/
|
||||
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function NodeEditorPage() {
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900">제어 시스템</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
const router = useRouter();
|
||||
|
||||
{/* 에디터 */}
|
||||
<FlowEditor />
|
||||
useEffect(() => {
|
||||
// /admin/dataflow 메인 페이지로 리다이렉트
|
||||
router.replace("/admin/dataflow");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-500">제어 관리 페이지로 이동중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,102 +2,78 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||
// 🎨 새로운 UI 컴포넌트 import
|
||||
import DataConnectionDesigner from "@/components/dataflow/connection/redesigned/DataConnectionDesigner";
|
||||
import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { loadDataflowRelationship } from "@/lib/api/dataflowSave";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
type Step = "list" | "design";
|
||||
type Step = "list" | "editor";
|
||||
|
||||
export default function DataFlowPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
const [editingDiagram, setEditingDiagram] = useState<DataFlowDiagram | null>(null);
|
||||
const [loadedRelationshipData, setLoadedRelationshipData] = useState<any>(null);
|
||||
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
|
||||
|
||||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
list: {
|
||||
title: "데이터 흐름 제어 관리",
|
||||
description: "생성된 제어들을 확인하고 관리하세요",
|
||||
icon: "📊",
|
||||
},
|
||||
design: {
|
||||
title: "새 제어 설계",
|
||||
description: "테이블 간 데이터 제어를 시각적으로 설계하세요",
|
||||
icon: "🎨",
|
||||
},
|
||||
};
|
||||
// 플로우 불러오기 핸들러
|
||||
const handleLoadFlow = async (flowId: number | null) => {
|
||||
if (flowId === null) {
|
||||
// 새 플로우 생성
|
||||
setLoadingFlowId(null);
|
||||
setCurrentStep("editor");
|
||||
return;
|
||||
}
|
||||
|
||||
// 다음 단계로 이동
|
||||
const goToNextStep = (nextStep: Step) => {
|
||||
setStepHistory((prev) => [...prev, nextStep]);
|
||||
setCurrentStep(nextStep);
|
||||
};
|
||||
try {
|
||||
// 기존 플로우 불러오기
|
||||
setLoadingFlowId(flowId);
|
||||
setCurrentStep("editor");
|
||||
|
||||
// 이전 단계로 이동
|
||||
const goToPreviousStep = () => {
|
||||
if (stepHistory.length > 1) {
|
||||
const newHistory = stepHistory.slice(0, -1);
|
||||
const previousStep = newHistory[newHistory.length - 1];
|
||||
setStepHistory(newHistory);
|
||||
setCurrentStep(previousStep);
|
||||
toast.success("플로우를 불러왔습니다.");
|
||||
} catch (error: any) {
|
||||
console.error("❌ 플로우 불러오기 실패:", error);
|
||||
toast.error(error.message || "플로우를 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 단계로 이동
|
||||
const goToStep = (step: Step) => {
|
||||
setCurrentStep(step);
|
||||
// 해당 단계까지의 히스토리만 유지
|
||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||
if (stepIndex !== -1) {
|
||||
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||
}
|
||||
// 목록으로 돌아가기
|
||||
const handleBackToList = () => {
|
||||
setCurrentStep("list");
|
||||
setLoadingFlowId(null);
|
||||
};
|
||||
|
||||
const handleSave = (relationships: TableRelationship[]) => {
|
||||
console.log("저장된 제어:", relationships);
|
||||
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
||||
setTimeout(() => {
|
||||
goToStep("list");
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
}, 0);
|
||||
};
|
||||
// 에디터 모드일 때는 전체 화면 사용
|
||||
const isEditorMode = currentStep === "editor";
|
||||
|
||||
// 제어 수정 핸들러
|
||||
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
|
||||
if (diagram) {
|
||||
// 기존 제어 수정 - 저장된 제어 정보 로드
|
||||
try {
|
||||
console.log("📖 제어 수정 모드:", diagram);
|
||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||
if (isEditorMode) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 에디터 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b bg-white p-4">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">노드 플로우 에디터</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 저장된 제어 정보 로드
|
||||
const relationshipData = await loadDataflowRelationship(diagram.diagramId);
|
||||
console.log("✅ 제어 정보 로드 완료:", relationshipData);
|
||||
|
||||
setEditingDiagram(diagram);
|
||||
setLoadedRelationshipData(relationshipData);
|
||||
goToNextStep("design");
|
||||
|
||||
toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 제어 정보 로드 실패:", error);
|
||||
toast.error(error.message || "제어 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 새 제어 생성 - 현재 페이지에서 처리
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
goToNextStep("design");
|
||||
}
|
||||
};
|
||||
{/* 플로우 에디터 */}
|
||||
<div className="flex-1">
|
||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
|
@ -106,32 +82,12 @@ export default function DataFlowPage() {
|
|||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">제어 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 제어를 시각적으로 설계하고 관리합니다</p>
|
||||
<p className="mt-2 text-gray-600">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="space-y-6">
|
||||
{/* 제어 목록 단계 */}
|
||||
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
|
||||
|
||||
{/* 제어 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{currentStep === "design" && (
|
||||
<DataConnectionDesigner
|
||||
onClose={() => {
|
||||
goToStep("list");
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
}}
|
||||
initialData={
|
||||
loadedRelationshipData || {
|
||||
connectionType: "data_save",
|
||||
}
|
||||
}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 플로우 목록 */}
|
||||
<DataFlowList onLoadFlow={handleLoadFlow} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,158 +20,129 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
|
||||
import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface DataFlowListProps {
|
||||
onDesignDiagram: (diagram: DataFlowDiagram | null) => void;
|
||||
// 노드 플로우 타입 정의
|
||||
interface NodeFlow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
||||
interface DataFlowListProps {
|
||||
onLoadFlow: (flowId: number | null) => void;
|
||||
}
|
||||
|
||||
export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
|
||||
const { user } = useAuth();
|
||||
const [diagrams, setDiagrams] = useState<DataFlowDiagram[]>([]);
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 사용자 회사 코드 가져오기 (기본값: "*")
|
||||
const companyCode = user?.company_code || user?.companyCode || "*";
|
||||
|
||||
// 모달 상태
|
||||
const [showCopyModal, setShowCopyModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedDiagramForAction, setSelectedDiagramForAction] = useState<DataFlowDiagram | null>(null);
|
||||
const [selectedFlow, setSelectedFlow] = useState<NodeFlow | null>(null);
|
||||
|
||||
// 목록 로드 함수 분리
|
||||
const loadDiagrams = useCallback(async () => {
|
||||
// 노드 플로우 목록 로드
|
||||
const loadFlows = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode);
|
||||
const response = await apiClient.get("/dataflow/node-flows");
|
||||
|
||||
// JSON API 응답을 기존 형식으로 변환
|
||||
const convertedDiagrams = response.diagrams.map((diagram) => {
|
||||
// relationships 구조 분석
|
||||
const relationships = diagram.relationships || {};
|
||||
|
||||
// 테이블 정보 추출
|
||||
const tables: string[] = [];
|
||||
if (relationships.fromTable?.tableName) {
|
||||
tables.push(relationships.fromTable.tableName);
|
||||
}
|
||||
if (
|
||||
relationships.toTable?.tableName &&
|
||||
relationships.toTable.tableName !== relationships.fromTable?.tableName
|
||||
) {
|
||||
tables.push(relationships.toTable.tableName);
|
||||
}
|
||||
|
||||
// 제어 수 계산 (actionGroups 기준)
|
||||
const actionGroups = relationships.actionGroups || [];
|
||||
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
|
||||
return count + (group.actions?.length || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
diagramId: diagram.diagram_id,
|
||||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||
diagramName: diagram.diagram_name,
|
||||
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
|
||||
relationshipType: "multi-relationship", // 다중 제어 타입
|
||||
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
|
||||
tableCount: tables.length,
|
||||
tables: tables,
|
||||
companyCode: diagram.company_code, // 회사 코드 추가
|
||||
createdAt: new Date(diagram.created_at || new Date()),
|
||||
createdBy: diagram.created_by || "SYSTEM",
|
||||
updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()),
|
||||
updatedBy: diagram.updated_by || "SYSTEM",
|
||||
lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
setDiagrams(convertedDiagrams);
|
||||
setTotal(response.pagination.total || 0);
|
||||
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
|
||||
if (response.data.success) {
|
||||
setFlows(response.data.data);
|
||||
} else {
|
||||
throw new Error(response.data.message || "플로우 목록 조회 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제어 목록 조회 실패", error);
|
||||
toast.error("제어 목록을 불러오는데 실패했습니다.");
|
||||
console.error("플로우 목록 조회 실패", error);
|
||||
toast.error("플로우 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, searchTerm, companyCode]);
|
||||
}, []);
|
||||
|
||||
// 제어 목록 로드
|
||||
// 플로우 목록 로드
|
||||
useEffect(() => {
|
||||
loadDiagrams();
|
||||
}, [loadDiagrams]);
|
||||
loadFlows();
|
||||
}, [loadFlows]);
|
||||
|
||||
const handleDelete = (diagram: DataFlowDiagram) => {
|
||||
setSelectedDiagramForAction(diagram);
|
||||
// 플로우 삭제
|
||||
const handleDelete = (flow: NodeFlow) => {
|
||||
setSelectedFlow(flow);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleCopy = (diagram: DataFlowDiagram) => {
|
||||
setSelectedDiagramForAction(diagram);
|
||||
setShowCopyModal(true);
|
||||
};
|
||||
|
||||
// 복사 확인
|
||||
const handleConfirmCopy = async () => {
|
||||
if (!selectedDiagramForAction) return;
|
||||
|
||||
// 플로우 복사
|
||||
const handleCopy = async (flow: NodeFlow) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram(
|
||||
selectedDiagramForAction.diagramId,
|
||||
companyCode,
|
||||
undefined,
|
||||
user?.userId || "SYSTEM",
|
||||
);
|
||||
toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
// 원본 플로우 데이터 가져오기
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "플로우 조회 실패");
|
||||
}
|
||||
|
||||
const originalFlow = response.data.data;
|
||||
|
||||
// 복사본 저장
|
||||
const copyResponse = await apiClient.post("/dataflow/node-flows", {
|
||||
flowName: `${flow.flowName} (복사본)`,
|
||||
flowDescription: flow.flowDescription,
|
||||
flowData: originalFlow.flowData,
|
||||
});
|
||||
|
||||
if (copyResponse.data.success) {
|
||||
toast.success(`플로우가 성공적으로 복사되었습니다`);
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(copyResponse.data.message || "플로우 복사 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제어 복사 실패:", error);
|
||||
toast.error("제어 복사에 실패했습니다.");
|
||||
console.error("플로우 복사 실패:", error);
|
||||
toast.error("플로우 복사에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowCopyModal(false);
|
||||
setSelectedDiagramForAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedDiagramForAction) return;
|
||||
if (!selectedFlow) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
|
||||
toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
|
||||
const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
if (response.data.success) {
|
||||
toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`);
|
||||
await loadFlows();
|
||||
} else {
|
||||
throw new Error(response.data.message || "플로우 삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제어 삭제 실패:", error);
|
||||
toast.error("제어 삭제에 실패했습니다.");
|
||||
console.error("플로우 삭제 실패:", error);
|
||||
toast.error("플로우 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteModal(false);
|
||||
setSelectedDiagramForAction(null);
|
||||
setSelectedFlow(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 검색 필터링
|
||||
const filteredFlows = flows.filter(
|
||||
(flow) =>
|
||||
flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -181,173 +152,125 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
placeholder="제어명, 테이블명으로 검색..."
|
||||
placeholder="플로우명, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-80 pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 제어 생성
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onLoadFlow(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 플로우 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 제어 목록 테이블 */}
|
||||
{/* 플로우 목록 테이블 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center">
|
||||
<Network className="mr-2 h-5 w-5" />
|
||||
데이터 흐름 제어 ({total})
|
||||
노드 플로우 목록 ({filteredFlows.length})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>제어명</TableHead>
|
||||
<TableHead>회사 코드</TableHead>
|
||||
<TableHead>테이블 수</TableHead>
|
||||
<TableHead>액션 수</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{diagrams.map((diagram) => (
|
||||
<TableRow key={diagram.diagramId} className="hover:bg-gray-50">
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="flex items-center font-medium text-gray-900">
|
||||
<Database className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{diagram.diagramName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
테이블: {diagram.tables.slice(0, 3).join(", ")}
|
||||
{diagram.tables.length > 3 && ` 외 ${diagram.tables.length - 3}개`}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{diagram.companyCode || "*"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Database className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{diagram.tableCount}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Network className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{diagram.relationshipCount}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(diagram.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-gray-400">
|
||||
<User className="mr-1 h-3 w-3" />
|
||||
{diagram.updatedBy}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{diagrams.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<div className="mb-2 text-lg font-medium">제어가 없습니다</div>
|
||||
<div className="text-sm">새 제어를 생성하여 테이블 간 데이터 제어를 설정해보세요.</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>플로우명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFlows.map((flow) => (
|
||||
<TableRow
|
||||
key={flow.flowId}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => onLoadFlow(flow.flowId)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center font-medium text-gray-900">
|
||||
<Network className="mr-2 h-4 w-4 text-blue-500" />
|
||||
{flow.flowName}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-500">{flow.flowDescription || "설명 없음"}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(flow.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(flow.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onLoadFlow(flow.flowId)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
불러오기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(flow)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(flow)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{filteredFlows.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<div className="mb-2 text-lg font-medium">플로우가 없습니다</div>
|
||||
<div className="text-sm">새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 복사 확인 모달 */}
|
||||
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>제어 복사</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 복사하시겠습니까?
|
||||
<br />
|
||||
새로운 제어는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCopyModal(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirmCopy} disabled={loading}>
|
||||
{loading ? "복사 중..." : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-600">제어 삭제</DialogTitle>
|
||||
<DialogTitle className="text-red-600">플로우 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 완전히 삭제하시겠습니까?
|
||||
“{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="font-medium text-red-600">
|
||||
이 작업은 되돌릴 수 없으며, 모든 제어 정보가 영구적으로 삭제됩니다.
|
||||
이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@
|
|||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar";
|
||||
import { Boxes, Settings } from "lucide-react";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
|
|
@ -48,10 +51,38 @@ const nodeTypes = {
|
|||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
function FlowEditorInner() {
|
||||
interface FlowEditorInnerProps {
|
||||
initialFlowId?: number | null;
|
||||
}
|
||||
|
||||
// 플로우 에디터 툴바 버튼 설정
|
||||
const flowToolbarButtons: ToolbarButton[] = [
|
||||
{
|
||||
id: "nodes",
|
||||
label: "노드",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
shortcut: "N",
|
||||
group: "source",
|
||||
panelWidth: 300,
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: "속성",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 350,
|
||||
},
|
||||
];
|
||||
|
||||
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
// 패널 표시 상태
|
||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
||||
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
|
|
@ -61,13 +92,50 @@ function FlowEditorInner() {
|
|||
onNodeDragStart,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
setShowPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
undo,
|
||||
redo,
|
||||
loadFlow,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
// 속성 패널 상태 동기화
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
||||
setShowPropertiesPanelLocal(true);
|
||||
}
|
||||
}, [selectedNodes, showPropertiesPanelLocal]);
|
||||
|
||||
// 초기 플로우 로드
|
||||
useEffect(() => {
|
||||
const fetchAndLoadFlow = async () => {
|
||||
if (initialFlowId) {
|
||||
try {
|
||||
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const flow = response.data.data;
|
||||
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
loadFlow(
|
||||
flow.flowId,
|
||||
flow.flowName,
|
||||
flow.flowDescription || "",
|
||||
flowData.nodes || [],
|
||||
flowData.edges || [],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndLoadFlow();
|
||||
}, [initialFlowId]);
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
|
|
@ -178,10 +246,29 @@ function FlowEditorInner() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 좌측 노드 팔레트 */}
|
||||
<div className="w-[250px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
{/* 좌측 통합 툴바 */}
|
||||
<LeftUnifiedToolbar
|
||||
buttons={flowToolbarButtons}
|
||||
panelStates={{
|
||||
nodes: { isOpen: showNodesPanel },
|
||||
properties: { isOpen: showPropertiesPanelLocal },
|
||||
}}
|
||||
onTogglePanel={(panelId) => {
|
||||
if (panelId === "nodes") {
|
||||
setShowNodesPanel(!showNodesPanel);
|
||||
} else if (panelId === "properties") {
|
||||
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
||||
setShowPropertiesPanel(!showPropertiesPanelLocal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 노드 라이브러리 패널 */}
|
||||
{showNodesPanel && (
|
||||
<div className="h-full w-[300px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
|
|
@ -224,8 +311,8 @@ function FlowEditorInner() {
|
|||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanel && (
|
||||
<div className="w-[350px] border-l bg-white">
|
||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
||||
<div className="h-full w-[350px] border-l bg-white">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -236,11 +323,15 @@ function FlowEditorInner() {
|
|||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
export function FlowEditor() {
|
||||
interface FlowEditorProps {
|
||||
initialFlowId?: number | null;
|
||||
}
|
||||
|
||||
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] min-h-[700px] w-full">
|
||||
<div className="h-full w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner />
|
||||
<FlowEditorInner initialFlowId={initialFlowId} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,11 @@
|
|||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react";
|
||||
import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
import { LoadFlowDialog } from "./dialogs/LoadFlowDialog";
|
||||
import { getNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
export function FlowToolbar() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
|
|
@ -21,7 +18,6 @@ export function FlowToolbar() {
|
|||
validateFlow,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isExecuting,
|
||||
isSaving,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
|
|
@ -30,7 +26,6 @@ export function FlowToolbar() {
|
|||
canUndo,
|
||||
canRedo,
|
||||
} = useFlowEditorStore();
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
|
|
@ -62,29 +57,6 @@ export function FlowToolbar() {
|
|||
alert("✅ JSON 파일로 내보내기 완료!");
|
||||
};
|
||||
|
||||
const handleLoad = async (flowId: number) => {
|
||||
try {
|
||||
const flow = await getNodeFlow(flowId);
|
||||
|
||||
// flowData가 이미 객체인지 문자열인지 확인
|
||||
const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
// Zustand 스토어의 loadFlow 함수 호출
|
||||
useFlowEditorStore
|
||||
.getState()
|
||||
.loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges);
|
||||
alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`);
|
||||
} catch (error) {
|
||||
console.error("플로우 불러오기 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
// TODO: 실행 로직 구현
|
||||
alert("실행 기능 구현 예정");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("삭제할 노드를 선택해주세요.");
|
||||
|
|
@ -98,94 +70,74 @@ export function FlowToolbar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadFlowDialog open={showLoadDialog} onOpenChange={setShowLoadDialog} onLoad={handleLoad} />
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 불러오기 */}
|
||||
<Button variant="outline" size="sm" onClick={() => setShowLoadDialog(true)} className="gap-1">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-xs">불러오기</span>
|
||||
</Button>
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="gap-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-xs">{isExecuting ? "실행 중..." : "테스트 실행"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig";
|
|||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export function NodePalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["source", "transform", "action"]));
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(["source", "transform", "action", "utility"]),
|
||||
);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
|
|
@ -25,7 +27,7 @@ export function NodePalette() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b bg-gray-50 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">노드 라이브러리</h3>
|
||||
|
|
@ -46,7 +48,6 @@ export function NodePalette() {
|
|||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span>{category.icon}</span>
|
||||
<span>{category.label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{nodes.length}</span>
|
||||
</button>
|
||||
|
|
@ -89,13 +90,8 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
|||
title={node.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 아이콘 */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-lg"
|
||||
style={{ backgroundColor: `${node.color}20` }}
|
||||
>
|
||||
{node.icon}
|
||||
</div>
|
||||
{/* 색상 인디케이터 (좌측) */}
|
||||
<div className="h-8 w-1 flex-shrink-0 rounded" style={{ backgroundColor: node.color }} />
|
||||
|
||||
{/* 라벨 및 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -104,7 +100,7 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 인디케이터 */}
|
||||
{/* 하단 색상 인디케이터 (hover 시) */}
|
||||
<div
|
||||
className="mt-2 h-1 w-full rounded-full opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: node.color }}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "tableSource",
|
||||
label: "테이블",
|
||||
icon: "📊",
|
||||
icon: "",
|
||||
description: "내부 데이터베이스 테이블에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#3B82F6", // 파란색
|
||||
|
|
@ -19,7 +19,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "externalDBSource",
|
||||
label: "외부 DB",
|
||||
icon: "🔌",
|
||||
icon: "",
|
||||
description: "외부 데이터베이스에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#F59E0B", // 주황색
|
||||
|
|
@ -27,7 +27,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "restAPISource",
|
||||
label: "REST API",
|
||||
icon: "📁",
|
||||
icon: "",
|
||||
description: "REST API를 호출하여 데이터를 가져옵니다",
|
||||
category: "source",
|
||||
color: "#10B981", // 초록색
|
||||
|
|
@ -35,7 +35,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "referenceLookup",
|
||||
label: "참조 조회",
|
||||
icon: "🔗",
|
||||
icon: "",
|
||||
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
|
||||
category: "source",
|
||||
color: "#A855F7", // 보라색
|
||||
|
|
@ -47,7 +47,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "condition",
|
||||
label: "조건 분기",
|
||||
icon: "⚡",
|
||||
icon: "",
|
||||
description: "조건에 따라 데이터 흐름을 분기합니다",
|
||||
category: "transform",
|
||||
color: "#EAB308", // 노란색
|
||||
|
|
@ -55,7 +55,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "dataTransform",
|
||||
label: "데이터 변환",
|
||||
icon: "🔧",
|
||||
icon: "",
|
||||
description: "데이터를 변환하거나 가공합니다",
|
||||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
|
|
@ -67,7 +67,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "insertAction",
|
||||
label: "INSERT",
|
||||
icon: "➕",
|
||||
icon: "",
|
||||
description: "데이터를 삽입합니다",
|
||||
category: "action",
|
||||
color: "#22C55E", // 초록색
|
||||
|
|
@ -75,7 +75,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "updateAction",
|
||||
label: "UPDATE",
|
||||
icon: "✏️",
|
||||
icon: "",
|
||||
description: "데이터를 수정합니다",
|
||||
category: "action",
|
||||
color: "#3B82F6", // 파란색
|
||||
|
|
@ -83,7 +83,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "deleteAction",
|
||||
label: "DELETE",
|
||||
icon: "❌",
|
||||
icon: "",
|
||||
description: "데이터를 삭제합니다",
|
||||
category: "action",
|
||||
color: "#EF4444", // 빨간색
|
||||
|
|
@ -91,7 +91,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "upsertAction",
|
||||
label: "UPSERT",
|
||||
icon: "🔄",
|
||||
icon: "",
|
||||
description: "데이터를 삽입하거나 수정합니다",
|
||||
category: "action",
|
||||
color: "#8B5CF6", // 보라색
|
||||
|
|
@ -103,7 +103,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "comment",
|
||||
label: "주석",
|
||||
icon: "💬",
|
||||
icon: "",
|
||||
description: "주석을 추가합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
|
|
@ -111,7 +111,7 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
{
|
||||
type: "log",
|
||||
label: "로그",
|
||||
icon: "🔍",
|
||||
icon: "",
|
||||
description: "로그를 출력합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
|
|
@ -122,22 +122,22 @@ export const NODE_CATEGORIES = [
|
|||
{
|
||||
id: "source",
|
||||
label: "데이터 소스",
|
||||
icon: "📂",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
label: "변환/조건",
|
||||
icon: "🔀",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "액션",
|
||||
icon: "⚡",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
id: "utility",
|
||||
label: "유틸리티",
|
||||
icon: "🛠️",
|
||||
icon: "",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "@/lib/utils/gridUtils";
|
||||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
|
|
@ -867,54 +868,58 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
||||
// 화면의 기본 테이블 정보 로드
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
|
||||
const loadTable = async () => {
|
||||
try {
|
||||
// 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화)
|
||||
const [columnsResponse, tableLabelResponse] = await Promise.all([
|
||||
tableTypeApi.getColumns(selectedScreen.tableName),
|
||||
tableTypeApi.getTableLabel(selectedScreen.tableName),
|
||||
]);
|
||||
const loadScreenTable = async () => {
|
||||
const tableName = selectedScreen?.tableName;
|
||||
if (!tableName) {
|
||||
setTables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
|
||||
tableName: col.tableName || selectedScreen.tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
// 우선순위: displayName(라벨) > columnLabel > column_label > columnName > column_name
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
dataType: col.dataType || col.data_type || col.dbType,
|
||||
webType: col.webType || col.web_type,
|
||||
input_type: col.inputType || col.input_type, // 🎯 input_type 필드 추가
|
||||
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
|
||||
isNullable: col.isNullable || col.is_nullable,
|
||||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||
columnDefault: col.columnDefault || col.column_default,
|
||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||
// 코드 카테고리 정보 추가
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
codeValue: col.codeValue || col.code_value,
|
||||
}));
|
||||
try {
|
||||
// 테이블 라벨 조회
|
||||
const tableListResponse = await tableManagementApi.getTableList();
|
||||
const currentTable =
|
||||
tableListResponse.success && tableListResponse.data
|
||||
? tableListResponse.data.find((t) => t.tableName === tableName)
|
||||
: null;
|
||||
const tableLabel = currentTable?.displayName || tableName;
|
||||
|
||||
const tableInfo: TableInfo = {
|
||||
tableName: selectedScreen.tableName,
|
||||
// 테이블 라벨이 있으면 우선 표시, 없으면 테이블명 그대로
|
||||
tableLabel: tableLabelResponse.tableLabel || selectedScreen.tableName,
|
||||
columns: columns,
|
||||
};
|
||||
setTables([tableInfo]); // 단일 테이블 정보만 설정
|
||||
} catch (error) {
|
||||
// console.error("테이블 정보 로드 실패:", error);
|
||||
toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`);
|
||||
}
|
||||
};
|
||||
// 현재 화면의 테이블 컬럼 정보 조회
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
loadTable();
|
||||
} else {
|
||||
// 테이블명이 없는 경우 테이블 목록 초기화
|
||||
setTables([]);
|
||||
}
|
||||
}, [selectedScreen?.tableName]);
|
||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
dataType: col.dataType || col.data_type || col.dbType,
|
||||
webType: col.webType || col.web_type,
|
||||
input_type: col.inputType || col.input_type,
|
||||
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
|
||||
isNullable: col.isNullable || col.is_nullable,
|
||||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||
columnDefault: col.columnDefault || col.column_default,
|
||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
codeValue: col.codeValue || col.code_value,
|
||||
}));
|
||||
|
||||
const tableInfo: TableInfo = {
|
||||
tableName,
|
||||
tableLabel,
|
||||
columns,
|
||||
};
|
||||
|
||||
setTables([tableInfo]);
|
||||
} catch (error) {
|
||||
console.error("화면 테이블 정보 로드 실패:", error);
|
||||
setTables([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreenTable();
|
||||
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -2044,6 +2049,30 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
gridColumns,
|
||||
});
|
||||
|
||||
// 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가
|
||||
let enhancedDefaultConfig = { ...component.defaultConfig };
|
||||
if (
|
||||
component.id === "repeater-field-group" &&
|
||||
tables &&
|
||||
tables.length > 0 &&
|
||||
tables[0].columns &&
|
||||
tables[0].columns.length > 0
|
||||
) {
|
||||
const firstColumn = tables[0].columns[0];
|
||||
enhancedDefaultConfig = {
|
||||
...enhancedDefaultConfig,
|
||||
fields: [
|
||||
{
|
||||
name: firstColumn.columnName,
|
||||
label: firstColumn.columnLabel || firstColumn.columnName,
|
||||
type: (firstColumn.widgetType as any) || "text",
|
||||
required: firstColumn.required || false,
|
||||
placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
|
|
@ -2056,7 +2085,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
...component.defaultConfig,
|
||||
...enhancedDefaultConfig,
|
||||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
|
|
|
|||
|
|
@ -362,20 +362,24 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
|
||||
const handleView = async (screen: ScreenDefinition) => {
|
||||
setScreenToPreview(screen);
|
||||
setPreviewDialogOpen(true);
|
||||
setPreviewLayout(null); // 이전 레이아웃 초기화
|
||||
setIsLoadingPreview(true);
|
||||
setPreviewDialogOpen(true); // 모달 먼저 열기
|
||||
|
||||
try {
|
||||
// 화면 레이아웃 로드
|
||||
const layoutData = await screenApi.getLayout(screen.screenId);
|
||||
console.log("📊 미리보기 레이아웃 로드:", layoutData);
|
||||
setPreviewLayout(layoutData);
|
||||
} catch (error) {
|
||||
console.error("❌ 레이아웃 로드 실패:", error);
|
||||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
// 모달이 열린 후에 레이아웃 로드
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 화면 레이아웃 로드
|
||||
const layoutData = await screenApi.getLayout(screen.screenId);
|
||||
console.log("📊 미리보기 레이아웃 로드:", layoutData);
|
||||
setPreviewLayout(layoutData);
|
||||
} catch (error) {
|
||||
console.error("❌ 레이아웃 로드 실패:", error);
|
||||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
}, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록
|
||||
};
|
||||
|
||||
const handleCopySuccess = () => {
|
||||
|
|
@ -877,7 +881,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
|
||||
{/* 화면 미리보기 다이얼로그 */}
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent className="data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 h-[95vh] max-w-[95vw]">
|
||||
<DialogContent className="h-[95vh] max-w-[95vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>화면 미리보기 - {screenToPreview?.screenName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,13 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
|
||||
// 카테고리별 컴포넌트 그룹화
|
||||
const componentsByCategory = useMemo(() => {
|
||||
// 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들)
|
||||
const hiddenInputComponents = ["text-input", "number-input", "date-input", "textarea-basic"];
|
||||
|
||||
return {
|
||||
input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && c.id === "file-upload"),
|
||||
input: allComponents.filter(
|
||||
(c) => c.category === ComponentCategory.INPUT && !hiddenInputComponents.includes(c.id),
|
||||
),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ interface DetailSettingsPanelProps {
|
|||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||
currentTableName?: string; // 현재 화면의 테이블명
|
||||
tables?: TableInfo[]; // 전체 테이블 목록
|
||||
}
|
||||
|
||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
|
|
@ -44,6 +45,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty,
|
||||
currentTable,
|
||||
currentTableName,
|
||||
tables = [], // 기본값 빈 배열
|
||||
}) => {
|
||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
|
@ -1104,6 +1106,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
// });
|
||||
return currentTable?.columns || [];
|
||||
})()}
|
||||
tables={tables} // 전체 테이블 목록 전달
|
||||
onChange={(newConfig) => {
|
||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
|
|
|
|||
|
|
@ -487,6 +487,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
|
||||
// 테이블 패널에서 드래그한 컴포넌트인지 확인
|
||||
const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName);
|
||||
|
||||
if (!componentId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
|
|
@ -509,18 +512,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 컴포넌트 정보 */}
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<span className="text-sm font-medium text-green-900">컴포넌트: {componentId}</span>
|
||||
{webType && currentBaseInputType && (
|
||||
<div className="mt-1 text-xs text-green-700">입력 타입: {currentBaseInputType}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 세부 타입 선택 */}
|
||||
{webType && availableDetailTypes.length > 1 && (
|
||||
{/* 세부 타입 선택 - 테이블 패널에서 드래그한 컴포넌트만 표시 */}
|
||||
{isFromTablePanel && webType && availableDetailTypes.length > 1 && (
|
||||
<div>
|
||||
<Label>세부 타입 선택</Label>
|
||||
<Label>세부 타입</Label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="세부 타입 선택" />
|
||||
|
|
@ -536,7 +531,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">입력 타입 "{currentBaseInputType}"의 세부 형태를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,476 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RepeaterInputProps {
|
||||
value?: RepeaterData;
|
||||
onChange?: (value: RepeaterData) => void;
|
||||
config?: RepeaterFieldGroupConfig;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 필드 그룹 컴포넌트
|
||||
* 여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 컴포넌트
|
||||
*/
|
||||
export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
config = { fields: [] },
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
className,
|
||||
}) => {
|
||||
// 설정 기본값
|
||||
const {
|
||||
fields = [],
|
||||
minItems = 0,
|
||||
maxItems = 10,
|
||||
addButtonText = "항목 추가",
|
||||
allowReorder = true,
|
||||
showIndex = true,
|
||||
collapsible = false,
|
||||
layout = "grid", // 기본값을 grid로 설정
|
||||
showDivider = true,
|
||||
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
|
||||
} = config;
|
||||
|
||||
// 로컬 상태 관리
|
||||
const [items, setItems] = useState<RepeaterData>(
|
||||
value.length > 0
|
||||
? value
|
||||
: minItems > 0
|
||||
? Array(minItems)
|
||||
.fill(null)
|
||||
.map(() => createEmptyItem())
|
||||
: [],
|
||||
);
|
||||
|
||||
// 접힌 상태 관리 (각 항목별)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
// 빈 항목 생성
|
||||
function createEmptyItem(): RepeaterItemData {
|
||||
const item: RepeaterItemData = {};
|
||||
fields.forEach((field) => {
|
||||
item[field.name] = "";
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
// 외부 value 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
setItems(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 항목 추가
|
||||
const handleAddItem = () => {
|
||||
if (items.length >= maxItems) {
|
||||
return;
|
||||
}
|
||||
const newItems = [...items, createEmptyItem()];
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
// 항목 제거
|
||||
const handleRemoveItem = (index: number) => {
|
||||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
|
||||
// 접힌 상태도 업데이트
|
||||
const newCollapsed = new Set(collapsedItems);
|
||||
newCollapsed.delete(index);
|
||||
setCollapsedItems(newCollapsed);
|
||||
};
|
||||
|
||||
// 필드 값 변경
|
||||
const handleFieldChange = (itemIndex: number, fieldName: string, value: any) => {
|
||||
const newItems = [...items];
|
||||
newItems[itemIndex] = {
|
||||
...newItems[itemIndex],
|
||||
[fieldName]: value,
|
||||
};
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
// 접기/펼치기 토글
|
||||
const toggleCollapse = (index: number) => {
|
||||
const newCollapsed = new Set(collapsedItems);
|
||||
if (newCollapsed.has(index)) {
|
||||
newCollapsed.delete(index);
|
||||
} else {
|
||||
newCollapsed.add(index);
|
||||
}
|
||||
setCollapsedItems(newCollapsed);
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 (순서 변경)
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
if (!allowReorder || readonly || disabled) return;
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
if (!allowReorder || readonly || disabled) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
if (!allowReorder || readonly || disabled || draggedIndex === null) return;
|
||||
e.preventDefault();
|
||||
|
||||
const newItems = [...items];
|
||||
const draggedItem = newItems[draggedIndex];
|
||||
newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, draggedItem);
|
||||
|
||||
setItems(newItems);
|
||||
onChange?.(newItems);
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: disabled || readonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="number"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
/>
|
||||
);
|
||||
|
||||
case "email":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="email"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="tel"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="text"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
maxLength={field.validation?.maxLength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드가 정의되지 않았을 때
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-orange-300 bg-orange-50 p-8 text-center">
|
||||
<p className="text-sm font-medium text-orange-900">필드가 정의되지 않았습니다</p>
|
||||
<p className="mt-2 text-xs text-orange-700">속성 패널에서 필드를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태 렌더링
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<p className="mb-4 text-sm text-gray-500">{emptyMessage}</p>
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" onClick={handleAddItem} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 카드 레이아웃일 때 필드 배치 (세로로 나열)
|
||||
const getFieldsLayoutClass = () => {
|
||||
return "space-y-3";
|
||||
};
|
||||
|
||||
// 그리드/테이블 형식 렌더링
|
||||
const renderGridLayout = () => {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
{/* 테이블 헤더 */}
|
||||
<div
|
||||
className="grid gap-2 border-b bg-gray-50 p-3 font-semibold"
|
||||
style={{
|
||||
gridTemplateColumns: `40px ${allowReorder ? "40px " : ""}${fields.map(() => "1fr").join(" ")} 80px`,
|
||||
}}
|
||||
>
|
||||
{showIndex && <div className="text-center text-sm">#</div>}
|
||||
{allowReorder && <div className="text-center text-sm"></div>}
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="text-sm text-gray-700">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center text-sm">작업</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
<div className="divide-y">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"grid gap-2 p-3 transition-colors hover:bg-gray-50",
|
||||
draggedIndex === itemIndex && "bg-blue-50 opacity-50",
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `40px ${allowReorder ? "40px " : ""}${fields.map(() => "1fr").join(" ")} 80px`,
|
||||
}}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
onDragStart={() => handleDragStart(itemIndex)}
|
||||
onDragOver={(e) => handleDragOver(e, itemIndex)}
|
||||
onDrop={(e) => handleDrop(e, itemIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<div className="flex items-center justify-center text-sm font-medium text-gray-600">
|
||||
{itemIndex + 1}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<div className="flex items-center justify-center">
|
||||
<GripVertical className="h-4 w-4 cursor-move text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드들 */}
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center">
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div className="flex items-center justify-center">
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(itemIndex)}
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 카드 형식 렌더링 (기존 방식)
|
||||
const renderCardLayout = () => {
|
||||
return (
|
||||
<>
|
||||
{items.map((item, itemIndex) => {
|
||||
const isCollapsed = collapsible && collapsedItems.has(itemIndex);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"relative transition-all",
|
||||
draggedIndex === itemIndex && "opacity-50",
|
||||
isCollapsed && "shadow-sm",
|
||||
)}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
onDragStart={() => handleDragStart(itemIndex)}
|
||||
onDragOver={(e) => handleDragOver(e, itemIndex)}
|
||||
onDrop={(e) => handleDrop(e, itemIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-gray-400" />
|
||||
)}
|
||||
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<CardTitle className="text-sm font-semibold text-gray-700">항목 {itemIndex + 1}</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 접기/펼치기 버튼 */}
|
||||
{collapsible && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleCollapse(itemIndex)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(itemIndex)}
|
||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
<div className={getFieldsLayoutClass()}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</label>
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{showDivider && itemIndex < items.length - 1 && <Separator className="mt-4" />}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 레이아웃에 따라 렌더링 방식 선택 */}
|
||||
{layout === "grid" ? renderGridLayout() : renderCardLayout()}
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddItem} className="w-full border-dashed">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 제한 안내 */}
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>현재: {items.length}개 항목</span>
|
||||
<span>
|
||||
(최소: {minItems}, 최대: {maxItems})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RepeaterInput.displayName = "RepeaterInput";
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RepeaterConfigPanelProps {
|
||||
config: RepeaterFieldGroupConfig;
|
||||
onChange: (config: RepeaterFieldGroupConfig) => void;
|
||||
tableColumns?: ColumnInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 필드 그룹 설정 패널
|
||||
*/
|
||||
export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({ config, onChange, tableColumns = [] }) => {
|
||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
// 이미 사용된 컬럼명 목록
|
||||
const usedColumnNames = useMemo(() => {
|
||||
return new Set(localFields.map((f) => f.name));
|
||||
}, [localFields]);
|
||||
|
||||
// 사용 가능한 컬럼 목록 (이미 사용된 컬럼 제외, 현재 편집 중인 필드는 포함)
|
||||
const getAvailableColumns = (currentFieldName?: string) => {
|
||||
return tableColumns.filter((col) => !usedColumnNames.has(col.columnName) || col.columnName === currentFieldName);
|
||||
};
|
||||
|
||||
const handleChange = (key: keyof RepeaterFieldGroupConfig, value: any) => {
|
||||
onChange({
|
||||
...config,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldsChange = (fields: RepeaterFieldDefinition[]) => {
|
||||
setLocalFields(fields);
|
||||
handleChange("fields", fields);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
const newField: RepeaterFieldDefinition = {
|
||||
name: `field_${localFields.length + 1}`,
|
||||
label: `필드 ${localFields.length + 1}`,
|
||||
type: "text",
|
||||
};
|
||||
handleFieldsChange([...localFields, newField]);
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
const updateField = (index: number, updates: Partial<RepeaterFieldDefinition>) => {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
handleFieldsChange(newFields);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필드 정의 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<Card key={index} className="border-2">
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">필드 {index + 1}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(index)}
|
||||
className="h-6 w-6 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">필드명 (컬럼)</Label>
|
||||
<Popover
|
||||
open={fieldNamePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: open })}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{field.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandEmpty>사용 가능한 컬럼이 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{getAvailableColumns(field.name).map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: (column.widgetType as RepeaterFieldType) || "text",
|
||||
});
|
||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
field.name === column.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel}</div>
|
||||
<div className="text-gray-500">{column.columnName}</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
placeholder="필드 라벨"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타입</Label>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) => updateField(index, { type: value as RepeaterFieldType })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택박스</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={field.placeholder || ""}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
placeholder="입력 안내"
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addField} className="w-full border-dashed">
|
||||
<Plus className="mr-2 h-3 w-3" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 항목 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">항목 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-min-items" className="text-xs">
|
||||
최소 항목 수
|
||||
</Label>
|
||||
<Input
|
||||
id="repeater-min-items"
|
||||
type="number"
|
||||
min={0}
|
||||
max={config.maxItems || 100}
|
||||
value={config.minItems || 0}
|
||||
onChange={(e) => handleChange("minItems", parseInt(e.target.value) || 0)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-max-items" className="text-xs">
|
||||
최대 항목 수
|
||||
</Label>
|
||||
<Input
|
||||
id="repeater-max-items"
|
||||
type="number"
|
||||
min={config.minItems || 0}
|
||||
max={100}
|
||||
value={config.maxItems || 10}
|
||||
onChange={(e) => handleChange("maxItems", parseInt(e.target.value) || 10)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-add-button-text" className="text-xs">
|
||||
추가 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="repeater-add-button-text"
|
||||
type="text"
|
||||
value={config.addButtonText || ""}
|
||||
onChange={(e) => handleChange("addButtonText", e.target.value)}
|
||||
placeholder="항목 추가"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">레이아웃</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repeater-layout" className="text-xs">
|
||||
표시 형식
|
||||
</Label>
|
||||
<Select
|
||||
value={config.layout || "grid"}
|
||||
onValueChange={(value) => handleChange("layout", value as "grid" | "card")}
|
||||
>
|
||||
<SelectTrigger id="repeater-layout" className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">테이블 형식 (Grid/Table)</SelectItem>
|
||||
<SelectItem value="card">카드 형식 (Card)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config.layout === "grid"
|
||||
? "행 단위로 데이터를 표시합니다 (테이블 형태)"
|
||||
: "각 항목을 카드로 표시합니다 (접기/펼치기 가능)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-allow-reorder"
|
||||
checked={config.allowReorder ?? true}
|
||||
onCheckedChange={(checked) => handleChange("allowReorder", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-allow-reorder" className="cursor-pointer text-xs font-normal">
|
||||
순서 변경 허용 (드래그 앤 드롭)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-show-index"
|
||||
checked={config.showIndex ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showIndex", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-show-index" className="cursor-pointer text-xs font-normal">
|
||||
항목 번호 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.layout === "card" && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-collapsible"
|
||||
checked={config.collapsible ?? false}
|
||||
onCheckedChange={(checked) => handleChange("collapsible", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-collapsible" className="cursor-pointer text-xs font-normal">
|
||||
항목 접기/펼치기 가능 (카드 모드 전용)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="repeater-show-divider"
|
||||
checked={config.showDivider ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showDivider", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="repeater-show-divider" className="cursor-pointer text-xs font-normal">
|
||||
항목 구분선 표시 (카드 모드 전용)
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-sm">
|
||||
<p className="mb-1 font-medium text-blue-900">💡 사용 예시</p>
|
||||
<ul className="space-y-1 text-xs text-blue-700">
|
||||
<li>• 담당자 정보 (이름, 전화번호, 이메일)</li>
|
||||
<li>• 학력 정보 (학교명, 전공, 졸업년도)</li>
|
||||
<li>• 경력 사항 (회사명, 직책, 기간)</li>
|
||||
<li>• 주소 목록 (주소, 우편번호, 상세주소)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RepeaterConfigPanel.displayName = "RepeaterConfigPanel";
|
||||
|
|
@ -193,45 +193,61 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
isInteractive,
|
||||
});
|
||||
|
||||
return (
|
||||
<NewComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
size={component.size || newComponent.defaultSize}
|
||||
position={component.position}
|
||||
style={component.style}
|
||||
config={component.componentConfig}
|
||||
componentConfig={component.componentConfig}
|
||||
value={currentValue} // formData에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
autoGeneration={component.autoGeneration}
|
||||
hidden={component.hidden}
|
||||
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
|
||||
isInteractive={isInteractive}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
tableName={tableName}
|
||||
onRefresh={onRefresh}
|
||||
onClose={onClose}
|
||||
screenId={screenId}
|
||||
mode={mode}
|
||||
isInModal={isInModal}
|
||||
originalData={originalData}
|
||||
allComponents={allComponents}
|
||||
onUpdateLayout={onUpdateLayout}
|
||||
onZoneClick={onZoneClick}
|
||||
// 테이블 선택된 행 정보 전달
|
||||
selectedRows={selectedRows}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
// 설정 변경 핸들러 전달
|
||||
onConfigChange={onConfigChange}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
// 렌더러 props 구성
|
||||
const rendererProps = {
|
||||
component,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: component.style,
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
value: currentValue, // formData에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
autoGeneration: component.autoGeneration,
|
||||
hidden: component.hidden,
|
||||
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
|
||||
isInteractive,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onChange: onFormDataChange, // onChange도 전달
|
||||
tableName,
|
||||
onRefresh,
|
||||
onClose,
|
||||
screenId,
|
||||
mode,
|
||||
isInModal,
|
||||
readonly: component.readonly,
|
||||
disabled: component.readonly,
|
||||
originalData,
|
||||
allComponents,
|
||||
onUpdateLayout,
|
||||
onZoneClick,
|
||||
// 테이블 선택된 행 정보 전달
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
// 설정 변경 핸들러 전달
|
||||
onConfigChange,
|
||||
refreshKey,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
if (
|
||||
typeof NewComponentRenderer === "function" &&
|
||||
NewComponentRenderer.prototype &&
|
||||
NewComponentRenderer.prototype.render
|
||||
) {
|
||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||
return rendererInstance.render();
|
||||
} else {
|
||||
// 함수형 컴포넌트
|
||||
return <NewComponentRenderer {...rendererProps} />;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import "./table-list/TableListRenderer";
|
|||
import "./card-display/CardDisplayRenderer";
|
||||
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||
import "./map/MapRenderer";
|
||||
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
|
||||
import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
|
||||
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
|
||||
|
||||
/**
|
||||
* Repeater Field Group 컴포넌트
|
||||
*/
|
||||
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
|
||||
const { component, value, onChange, readonly, disabled } = props;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 값이 JSON 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = [];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
parsedValue = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={(newValue) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
}}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Repeater Field Group 렌더러
|
||||
* 여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 컴포넌트
|
||||
*/
|
||||
export class RepeaterFieldGroupRenderer extends AutoRegisteringComponentRenderer {
|
||||
/**
|
||||
* 컴포넌트 정의
|
||||
*/
|
||||
static componentDefinition: ComponentDefinition = {
|
||||
id: "repeater-field-group",
|
||||
name: "반복 필드 그룹",
|
||||
nameEng: "Repeater Field Group",
|
||||
description: "여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 반복 가능한 필드 그룹",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "array", // 배열 데이터를 다룸
|
||||
icon: Layers,
|
||||
component: RepeaterFieldGroupRenderer,
|
||||
configPanel: RepeaterConfigPanel,
|
||||
defaultSize: {
|
||||
width: 600,
|
||||
height: 400, // 여러 항목과 필드를 표시할 수 있도록 높이 설정
|
||||
},
|
||||
defaultConfig: {
|
||||
fields: [], // 빈 배열로 시작 - 사용자가 직접 필드 추가
|
||||
minItems: 1, // 기본 1개 항목
|
||||
maxItems: 20,
|
||||
addButtonText: "항목 추가",
|
||||
allowReorder: true,
|
||||
showIndex: true,
|
||||
collapsible: false,
|
||||
layout: "grid",
|
||||
showDivider: true,
|
||||
emptyMessage: "필드를 먼저 정의하세요.",
|
||||
},
|
||||
tags: ["repeater", "fieldgroup", "dynamic", "multi", "form", "array", "fields"],
|
||||
author: "System",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 렌더링
|
||||
*/
|
||||
render(): React.ReactElement {
|
||||
return <RepeaterFieldGroupComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
RepeaterFieldGroupRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
RepeaterFieldGroupRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@ import { SplitPanelLayoutConfig } from "./types";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, Search, GripVertical, Loader2 } from "lucide-react";
|
||||
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -35,17 +36,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
// 데이터 상태
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
const [rightData, setRightData] = useState<any>(null);
|
||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
||||
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||
const { toast } = useToast();
|
||||
|
||||
// 리사이저 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 컴포넌트 스타일
|
||||
const componentStyle: React.CSSProperties = {
|
||||
|
|
@ -69,7 +73,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const result = await dataApi.getTableData(leftTableName, {
|
||||
page: 1,
|
||||
size: 100,
|
||||
searchTerm: leftSearchQuery || undefined,
|
||||
// searchTerm 제거 - 클라이언트 사이드에서 필터링
|
||||
});
|
||||
setLeftData(result.data);
|
||||
} catch (error) {
|
||||
|
|
@ -82,7 +86,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
} finally {
|
||||
setIsLoadingLeft(false);
|
||||
}
|
||||
}, [componentConfig.leftPanel?.tableName, leftSearchQuery, isDesignMode, toast]);
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode, toast]);
|
||||
|
||||
// 우측 데이터 로드
|
||||
const loadRightData = useCallback(
|
||||
|
|
@ -100,7 +104,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
||||
setRightData(detail);
|
||||
} else if (relationshipType === "join") {
|
||||
// 조인 모드: 다른 테이블의 관련 데이터
|
||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
||||
const leftTable = componentConfig.leftPanel?.tableName;
|
||||
|
|
@ -114,13 +118,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
rightColumn,
|
||||
leftValue,
|
||||
);
|
||||
setRightData(joinedData[0] || null); // 첫 번째 관련 레코드
|
||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||
}
|
||||
} else {
|
||||
// 커스텀 모드: 상세 정보로 폴백
|
||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
||||
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
||||
setRightData(detail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("우측 데이터 로드 실패:", error);
|
||||
|
|
@ -146,11 +145,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const handleLeftItemSelect = useCallback(
|
||||
(item: any) => {
|
||||
setSelectedLeftItem(item);
|
||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||
loadRightData(item);
|
||||
},
|
||||
[loadRightData],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
const toggleRightItemExpansion = useCallback((itemId: string | number) => {
|
||||
setExpandedRightItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 컬럼명을 라벨로 변환하는 함수
|
||||
const getColumnLabel = useCallback(
|
||||
(columnName: string) => {
|
||||
const column = rightTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName);
|
||||
return column?.columnLabel || column?.column_label || column?.displayName || columnName;
|
||||
},
|
||||
[rightTableColumns],
|
||||
);
|
||||
|
||||
// 우측 테이블 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadRightTableColumns = async () => {
|
||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||
if (!rightTableName || isDesignMode) return;
|
||||
|
||||
try {
|
||||
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
||||
setRightTableColumns(columnsResponse || []);
|
||||
} catch (error) {
|
||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadRightTableColumns();
|
||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||
|
|
@ -159,17 +198,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDesignMode, componentConfig.autoLoad]);
|
||||
|
||||
// 검색어 변경 시 재로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && leftSearchQuery) {
|
||||
const timer = setTimeout(() => {
|
||||
loadLeftData();
|
||||
}, 300); // 디바운스
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftSearchQuery, isDesignMode]);
|
||||
|
||||
// 리사이저 드래그 핸들러
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!resizable) return;
|
||||
|
|
@ -179,11 +207,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const containerWidth = (e.currentTarget as HTMLElement)?.offsetWidth || 1000;
|
||||
const newLeftWidth = (e.clientX / containerWidth) * 100;
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
if (newLeftWidth > 20 && newLeftWidth < 80) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
const relativeX = e.clientX - containerRect.left;
|
||||
const newLeftWidth = (relativeX / containerWidth) * 100;
|
||||
|
||||
// 최소/최대 너비 제한 (20% ~ 80%)
|
||||
if (newLeftWidth >= 20 && newLeftWidth <= 80) {
|
||||
setLeftWidth(newLeftWidth);
|
||||
}
|
||||
},
|
||||
|
|
@ -196,10 +228,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
React.useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove as any);
|
||||
// 드래그 중에는 텍스트 선택 방지
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove as any);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
|
|
@ -207,6 +246,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={componentStyle}
|
||||
onClick={(e) => {
|
||||
if (isDesignMode) {
|
||||
|
|
@ -286,32 +326,57 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
<span className="ml-2 text-sm text-gray-500">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
) : leftData.length > 0 ? (
|
||||
// 실제 데이터 표시
|
||||
leftData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index;
|
||||
const isSelected = selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item);
|
||||
// 첫 번째 2-3개 필드를 표시
|
||||
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
||||
const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`;
|
||||
const displaySubtitle = keys[1] ? item[keys[1]] : null;
|
||||
) : (
|
||||
(() => {
|
||||
// 검색 필터링 (클라이언트 사이드)
|
||||
const filteredLeftData = leftSearchQuery
|
||||
? leftData.filter((item) => {
|
||||
const searchLower = leftSearchQuery.toLowerCase();
|
||||
return Object.entries(item).some(([key, value]) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
})
|
||||
: leftData;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
isSelected ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate font-medium">{displayTitle}</div>
|
||||
{displaySubtitle && <div className="truncate text-xs text-gray-500">{displaySubtitle}</div>}
|
||||
return filteredLeftData.length > 0 ? (
|
||||
// 실제 데이터 표시
|
||||
filteredLeftData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index;
|
||||
const isSelected =
|
||||
selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item);
|
||||
// 첫 번째 2-3개 필드를 표시
|
||||
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
||||
const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`;
|
||||
const displaySubtitle = keys[1] ? item[keys[1]] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
isSelected ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate font-medium">{displayTitle}</div>
|
||||
{displaySubtitle && <div className="truncate text-xs text-gray-500">{displaySubtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 검색 결과 없음
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
{leftSearchQuery ? (
|
||||
<>
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-400">다른 검색어를 입력해보세요.</p>
|
||||
</>
|
||||
) : (
|
||||
"데이터가 없습니다."
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 데이터 없음
|
||||
<div className="py-8 text-center text-sm text-gray-500">데이터가 없습니다.</div>
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -356,30 +421,133 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{/* 우측 상세 데이터 */}
|
||||
{/* 우측 데이터 */}
|
||||
{isLoadingRight ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="mt-2 text-sm text-gray-500">상세 정보를 불러오는 중...</p>
|
||||
<p className="mt-2 text-sm text-gray-500">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
// 실제 데이터 표시
|
||||
<div className="space-y-2">
|
||||
{Object.entries(rightData).map(([key, value]) => {
|
||||
// null, undefined, 빈 문자열 제외
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
Array.isArray(rightData) ? (
|
||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||
(() => {
|
||||
// 검색 필터링
|
||||
const filteredData = rightSearchQuery
|
||||
? rightData.filter((item) => {
|
||||
const searchLower = rightSearchQuery.toLowerCase();
|
||||
return Object.entries(item).some(([key, value]) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
})
|
||||
: rightData;
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-1 text-xs font-semibold tracking-wide text-gray-500 uppercase">{key}</div>
|
||||
<div className="text-sm text-gray-900">{String(value)}</div>
|
||||
return filteredData.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-2 text-xs text-gray-500">
|
||||
{filteredData.length}개의 관련 데이터
|
||||
{rightSearchQuery && filteredData.length !== rightData.length && (
|
||||
<span className="ml-1 text-blue-600">(전체 {rightData.length}개 중)</span>
|
||||
)}
|
||||
</div>
|
||||
{filteredData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || index;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
const firstValues = Object.entries(item)
|
||||
.filter(([key]) => !key.toLowerCase().includes("id"))
|
||||
.slice(0, 3);
|
||||
const allValues = Object.entries(item).filter(
|
||||
([key, value]) => value !== null && value !== undefined && value !== "",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
{/* 요약 정보 (클릭 가능) */}
|
||||
<div
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
className="cursor-pointer p-3 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{firstValues.map(([key, value], idx) => (
|
||||
<div key={key} className="mb-1 last:mb-0">
|
||||
<div className="text-xs font-medium text-gray-500">{getColumnLabel(key)}</div>
|
||||
<div className="truncate text-sm text-gray-900" title={String(value || "-")}>
|
||||
{String(value || "-")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-start pt-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 (확장 시 표시) */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<div className="mb-2 text-xs font-semibold text-gray-700">전체 상세 정보</div>
|
||||
<div className="overflow-auto rounded-md border border-gray-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{allValues.map(([key, value]) => (
|
||||
<tr key={key} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium whitespace-nowrap text-gray-600">
|
||||
{getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="px-3 py-2 break-all text-gray-900">{String(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
{rightSearchQuery ? (
|
||||
<>
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-400">다른 검색어를 입력해보세요.</p>
|
||||
</>
|
||||
) : (
|
||||
"관련 데이터가 없습니다."
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})()
|
||||
) : (
|
||||
// 상세 모드: 단일 객체를 상세 정보로 표시
|
||||
<div className="space-y-2">
|
||||
{Object.entries(rightData).map(([key, value]) => {
|
||||
// null, undefined, 빈 문자열 제외
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-1 text-xs font-semibold tracking-wide text-gray-500 uppercase">{key}</div>
|
||||
<div className="text-sm text-gray-900">{String(value)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : selectedLeftItem && isDesignMode ? (
|
||||
// 디자인 모드: 샘플 데이터
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { TableInfo } from "@/types/screen";
|
||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
interface SplitPanelLayoutConfigPanelProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
|
|
@ -33,24 +34,71 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
|
||||
const [rightColumnOpen, setRightColumnOpen] = useState(false);
|
||||
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
|
||||
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||
|
||||
// screenTableName이 변경되면 leftPanel.tableName 자동 업데이트
|
||||
// screenTableName이 변경되면 좌측 패널 테이블을 항상 화면 테이블로 설정
|
||||
useEffect(() => {
|
||||
if (screenTableName) {
|
||||
// 좌측 패널 테이블명 업데이트
|
||||
// 좌측 패널은 항상 현재 화면의 테이블 사용
|
||||
if (config.leftPanel?.tableName !== screenTableName) {
|
||||
updateLeftPanel({ tableName: screenTableName });
|
||||
}
|
||||
|
||||
// 관계 타입이 detail이면 우측 패널도 동일한 테이블 사용
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
if (relationshipType === "detail" && config.rightPanel?.tableName !== screenTableName) {
|
||||
updateRightPanel({ tableName: screenTableName });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenTableName]);
|
||||
|
||||
// 테이블 컬럼 로드 함수
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (loadedTableColumns[tableName] || loadingColumns[tableName]) {
|
||||
return; // 이미 로드되었거나 로딩 중
|
||||
}
|
||||
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
||||
|
||||
try {
|
||||
const columnsResponse = await tableTypeApi.getColumns(tableName);
|
||||
console.log(`📊 테이블 ${tableName} 컬럼 응답:`, columnsResponse);
|
||||
|
||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
dataType: col.dataType || col.data_type || col.dbType,
|
||||
webType: col.webType || col.web_type,
|
||||
input_type: col.inputType || col.input_type,
|
||||
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
|
||||
isNullable: col.isNullable || col.is_nullable,
|
||||
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
|
||||
columnDefault: col.columnDefault || col.column_default,
|
||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
codeValue: col.codeValue || col.code_value,
|
||||
}));
|
||||
|
||||
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
|
||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] }));
|
||||
} finally {
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (config.leftPanel?.tableName) {
|
||||
loadTableColumns(config.leftPanel.tableName);
|
||||
}
|
||||
}, [config.leftPanel?.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.rightPanel?.tableName) {
|
||||
loadTableColumns(config.rightPanel.tableName);
|
||||
}
|
||||
}, [config.rightPanel?.tableName]);
|
||||
|
||||
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
|
||||
console.log(" - config:", config);
|
||||
console.log(" - tables:", tables);
|
||||
|
|
@ -83,25 +131,24 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 좌측 테이블은 현재 화면의 테이블 (screenTableName) 사용
|
||||
// 좌측 테이블 컬럼 (로드된 컬럼 사용)
|
||||
const leftTableColumns = useMemo(() => {
|
||||
const tableName = screenTableName || config.leftPanel?.tableName;
|
||||
const table = tables.find((t) => t.tableName === tableName);
|
||||
return table?.columns || [];
|
||||
}, [tables, screenTableName, config.leftPanel?.tableName]);
|
||||
const tableName = config.leftPanel?.tableName || screenTableName;
|
||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
||||
}, [loadedTableColumns, config.leftPanel?.tableName, screenTableName]);
|
||||
|
||||
// 우측 테이블의 컬럼 목록 가져오기
|
||||
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
||||
const rightTableColumns = useMemo(() => {
|
||||
const table = tables.find((t) => t.tableName === config.rightPanel?.tableName);
|
||||
return table?.columns || [];
|
||||
}, [tables, config.rightPanel?.tableName]);
|
||||
const tableName = config.rightPanel?.tableName;
|
||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
||||
}, [loadedTableColumns, config.rightPanel?.tableName]);
|
||||
|
||||
// 테이블 데이터 로딩 상태 확인
|
||||
if (!tables || tables.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<p className="text-sm text-yellow-800">⚠️ 테이블 데이터를 불러올 수 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-yellow-600">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">테이블 데이터를 불러올 수 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
화면에 테이블이 연결되지 않았거나 테이블 목록이 로드되지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -113,23 +160,12 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 테이블 정보 표시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-600">📊 사용 가능한 테이블: {tables.length}개</p>
|
||||
</div>
|
||||
|
||||
{/* 관계 타입 선택 (최상단) */}
|
||||
<div className="space-y-3 rounded-lg border-2 border-indigo-200 bg-indigo-50 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-white">
|
||||
<span className="text-sm font-bold">1</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-indigo-900">패널 관계 타입 선택</h3>
|
||||
</div>
|
||||
<p className="text-xs text-indigo-700">좌측과 우측 패널 간의 데이터 관계를 선택하세요</p>
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">패널 관계 타입</h3>
|
||||
<Select
|
||||
value={relationshipType}
|
||||
onValueChange={(value: "join" | "detail" | "custom") => {
|
||||
onValueChange={(value: "join" | "detail") => {
|
||||
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
|
||||
if (value === "detail" && screenTableName) {
|
||||
updateRightPanel({
|
||||
|
|
@ -159,24 +195,13 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
<span className="text-xs text-gray-500">좌측 테이블 → 우측 관련 테이블 (다른 테이블)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">커스텀 (CUSTOM)</span>
|
||||
<span className="text-xs text-gray-500">사용자 정의 관계</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 좌측 패널 설정 (마스터) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white">
|
||||
<span className="text-sm font-bold">2</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">좌측 패널 설정 (마스터)</h3>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
|
|
@ -188,9 +213,11 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (현재 화면)</Label>
|
||||
<Label>테이블 (현재 화면 고정)</Label>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-sm font-medium text-gray-900">{screenTableName || "테이블이 지정되지 않음"}</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">좌측 패널은 현재 화면의 테이블 데이터를 표시합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,14 +241,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* 우측 패널 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-600 text-white">
|
||||
<span className="text-sm font-bold">3</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
우측 패널 설정 ({relationshipType === "detail" ? "상세" : relationshipType === "join" ? "조인" : "커스텀"})
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
|
|
@ -234,16 +254,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
||||
{relationshipType === "detail" ? (
|
||||
// 상세 모드: 좌측과 동일한 테이블 (비활성화)
|
||||
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (좌측과 동일)</Label>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-sm font-medium text-gray-900">{screenTableName || "테이블이 지정되지 않음"}</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">상세 모드에서는 좌측과 동일한 테이블을 사용합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 조인/커스텀 모드: 전체 테이블에서 선택 가능
|
||||
// 조인 모드: 전체 테이블에서 선택 가능
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택 (전체 테이블)</Label>
|
||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||
|
|
@ -289,7 +311,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 - 조인/커스텀 모드에서만 표시 */}
|
||||
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
||||
{relationshipType !== "detail" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-sm font-semibold">컬럼 매핑 (외래키 관계)</Label>
|
||||
|
|
@ -418,12 +440,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-600 text-white">
|
||||
<span className="text-sm font-bold">4</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">레이아웃 설정</h3>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">레이아웃 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ export interface SplitPanelLayoutConfig {
|
|||
|
||||
// 좌측 선택 항목과의 관계 설정
|
||||
relation?: {
|
||||
type: "join" | "detail" | "custom"; // 관계 타입
|
||||
type: "join" | "detail"; // 관계 타입
|
||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼
|
||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||
condition?: string; // 커스텀 조건
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ interface FlowEditorState {
|
|||
flowDescription: string;
|
||||
|
||||
// UI 상태
|
||||
isExecuting: boolean;
|
||||
isSaving: boolean;
|
||||
showValidationPanel: boolean;
|
||||
showPropertiesPanel: boolean;
|
||||
|
|
@ -131,7 +130,6 @@ interface FlowEditorState {
|
|||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value: boolean) => void;
|
||||
setIsSaving: (value: boolean) => void;
|
||||
setShowValidationPanel: (value: boolean) => void;
|
||||
setShowPropertiesPanel: (value: boolean) => void;
|
||||
|
|
@ -169,7 +167,6 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
|
|||
flowId: null,
|
||||
flowName: "새 제어 플로우",
|
||||
flowDescription: "",
|
||||
isExecuting: false,
|
||||
isSaving: false,
|
||||
showValidationPanel: false,
|
||||
showPropertiesPanel: true,
|
||||
|
|
@ -599,7 +596,6 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
|
|||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value) => set({ isExecuting: value }),
|
||||
setIsSaving: (value) => set({ isSaving: value }),
|
||||
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
||||
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),
|
||||
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
|
||||
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
||||
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -50,7 +51,10 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
|||
const module = await importFn();
|
||||
|
||||
// 모듈에서 ConfigPanel 컴포넌트 추출
|
||||
const ConfigPanelComponent = module[`${toPascalCase(componentId)}ConfigPanel`] || module.default;
|
||||
const ConfigPanelComponent =
|
||||
module[`${toPascalCase(componentId)}ConfigPanel`] ||
|
||||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||
module.default;
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
console.error(`컴포넌트 "${componentId}"의 ConfigPanel을 모듈에서 찾을 수 없습니다.`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 반복 필드 그룹(Repeater) 타입 정의
|
||||
*/
|
||||
|
||||
export type RepeaterFieldType = "text" | "number" | "email" | "tel" | "date" | "select" | "textarea";
|
||||
|
||||
/**
|
||||
* 반복 그룹 내 개별 필드 정의
|
||||
*/
|
||||
export interface RepeaterFieldDefinition {
|
||||
name: string; // 필드 이름 (키)
|
||||
label: string; // 필드 라벨
|
||||
type: RepeaterFieldType; // 입력 타입
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: Array<{ label: string; value: string }>; // select용
|
||||
width?: string; // 필드 너비 (예: "200px", "50%")
|
||||
validation?: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 필드 그룹 설정
|
||||
*/
|
||||
export interface RepeaterFieldGroupConfig {
|
||||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
addButtonText?: string; // 추가 버튼 텍스트
|
||||
removeButtonText?: string; // 제거 버튼 텍스트 (보통 아이콘)
|
||||
allowReorder?: boolean; // 순서 변경 가능 여부
|
||||
showIndex?: boolean; // 인덱스 번호 표시 여부
|
||||
collapsible?: boolean; // 각 항목을 접을 수 있는지 (카드 모드일 때만)
|
||||
layout?: "grid" | "card"; // 레이아웃 타입: grid(테이블 행) 또는 card(카드 형식)
|
||||
showDivider?: boolean; // 항목 사이 구분선 표시 (카드 모드일 때만)
|
||||
emptyMessage?: string; // 항목이 없을 때 메시지
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 그룹 항목 데이터
|
||||
*/
|
||||
export type RepeaterItemData = Record<string, any>;
|
||||
|
||||
/**
|
||||
* 반복 그룹 전체 데이터 (배열)
|
||||
*/
|
||||
export type RepeaterData = RepeaterItemData[];
|
||||
|
|
@ -78,6 +78,7 @@ export interface WidgetComponent extends BaseComponent {
|
|||
fileConfig?: FileTypeConfig;
|
||||
entityConfig?: EntityTypeConfig;
|
||||
buttonConfig?: ButtonTypeConfig;
|
||||
arrayConfig?: ArrayTypeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,6 +209,20 @@ export interface TextTypeConfig {
|
|||
rows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열(다중 입력) 타입 설정
|
||||
*/
|
||||
export interface ArrayTypeConfig {
|
||||
itemType?: "text" | "number" | "email" | "tel"; // 각 항목의 입력 타입
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
placeholder?: string; // 입력 필드 placeholder
|
||||
addButtonText?: string; // + 버튼 텍스트
|
||||
removeButtonText?: string; // - 버튼 텍스트 (보통 아이콘)
|
||||
allowReorder?: boolean; // 순서 변경 가능 여부
|
||||
showIndex?: boolean; // 인덱스 번호 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 타입 설정
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue