분할 패널 및 반복 필드 그룹 컴포넌트

This commit is contained in:
kjs 2025-10-16 15:05:24 +09:00
parent 716cfcb2cf
commit a0dde51109
26 changed files with 1899 additions and 753 deletions

View File

@ -112,9 +112,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
json: "textarea",
jsonb: "textarea",
// 배열 타입 (텍스트로 처리)
ARRAY: "textarea",
// UUID 타입
uuid: "text",
};

View File

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

View File

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

View File

@ -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>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<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>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
&ldquo;{selectedFlow?.flowName}&rdquo; ?
<br />
<span className="font-medium text-red-600">
, .
, .
</span>
</DialogDescription>
</DialogHeader>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,10 +32,9 @@ export interface SplitPanelLayoutConfig {
// 좌측 선택 항목과의 관계 설정
relation?: {
type: "join" | "detail" | "custom"; // 관계 타입
type: "join" | "detail"; // 관계 타입
leftColumn?: string; // 좌측 테이블의 연결 컬럼
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
condition?: string; // 커스텀 조건
};
};

View File

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

View File

@ -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을 모듈에서 찾을 수 없습니다.`);

View File

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

View File

@ -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; // 인덱스 번호 표시 여부
}
/**
*
*/