Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
87f3959036
|
|
@ -10,9 +10,22 @@ const prisma = new PrismaClient();
|
||||||
|
|
||||||
// 업로드 디렉토리 설정 (회사별로 분리)
|
// 업로드 디렉토리 설정 (회사별로 분리)
|
||||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||||
|
|
||||||
|
// 디렉토리 생성 함수 (에러 핸들링 포함)
|
||||||
|
const ensureUploadDir = () => {
|
||||||
|
try {
|
||||||
if (!fs.existsSync(baseUploadDir)) {
|
if (!fs.existsSync(baseUploadDir)) {
|
||||||
fs.mkdirSync(baseUploadDir, { recursive: true });
|
fs.mkdirSync(baseUploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`업로드 디렉토리 생성 실패: ${error}. 기존 디렉토리를 사용합니다.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화 시 디렉토리 확인
|
||||||
|
ensureUploadDir();
|
||||||
|
|
||||||
// 회사별 + 날짜별 디렉토리 생성 함수
|
// 회사별 + 날짜별 디렉토리 생성 함수
|
||||||
const getCompanyUploadDir = (companyCode: string, dateFolder?: string) => {
|
const getCompanyUploadDir = (companyCode: string, dateFolder?: string) => {
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ COPY --from=build /app/dist ./dist
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Create logs directory and set permissions
|
# Create logs and uploads directories and set permissions
|
||||||
RUN mkdir -p logs && chown -R appuser:appgroup logs && chmod -R 755 logs
|
RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
|
||||||
|
|
@ -1,361 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from "react";
|
|
||||||
import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { AlertModal } from "@/components/common/AlertModal";
|
|
||||||
import {
|
|
||||||
useComponents,
|
|
||||||
useComponentCategories,
|
|
||||||
useComponentStatistics,
|
|
||||||
useDeleteComponent,
|
|
||||||
useCreateComponent,
|
|
||||||
useUpdateComponent,
|
|
||||||
} from "@/hooks/admin/useComponents";
|
|
||||||
import { ComponentFormModal } from "@/components/admin/ComponentFormModal";
|
|
||||||
|
|
||||||
// 컴포넌트 카테고리 정의
|
|
||||||
const COMPONENT_CATEGORIES = [
|
|
||||||
{ id: "input", name: "입력", color: "blue" },
|
|
||||||
{ id: "action", name: "액션", color: "green" },
|
|
||||||
{ id: "display", name: "표시", color: "purple" },
|
|
||||||
{ id: "layout", name: "레이아웃", color: "orange" },
|
|
||||||
{ id: "other", name: "기타", color: "gray" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ComponentManagementPage() {
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
|
||||||
const [sortBy, setSortBy] = useState<string>("sort_order");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
const [selectedComponent, setSelectedComponent] = useState<any>(null);
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
||||||
const [showNewComponentModal, setShowNewComponentModal] = useState(false);
|
|
||||||
const [showEditComponentModal, setShowEditComponentModal] = useState(false);
|
|
||||||
|
|
||||||
// 컴포넌트 데이터 가져오기
|
|
||||||
const {
|
|
||||||
data: componentsData,
|
|
||||||
isLoading: loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useComponents({
|
|
||||||
category: selectedCategory === "all" ? undefined : selectedCategory,
|
|
||||||
active: "Y",
|
|
||||||
search: searchTerm,
|
|
||||||
sort: sortBy,
|
|
||||||
order: sortOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 카테고리와 통계 데이터
|
|
||||||
const { data: categories } = useComponentCategories();
|
|
||||||
const { data: statistics } = useComponentStatistics();
|
|
||||||
|
|
||||||
// 뮤테이션
|
|
||||||
const deleteComponentMutation = useDeleteComponent();
|
|
||||||
const createComponentMutation = useCreateComponent();
|
|
||||||
const updateComponentMutation = useUpdateComponent();
|
|
||||||
|
|
||||||
// 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태)
|
|
||||||
const components = componentsData?.components || [];
|
|
||||||
|
|
||||||
// 카테고리별 통계 (백엔드에서 가져온 데이터 사용)
|
|
||||||
const categoryStats = useMemo(() => {
|
|
||||||
if (!statistics?.byCategory) return {};
|
|
||||||
|
|
||||||
const stats: Record<string, number> = {};
|
|
||||||
statistics.byCategory.forEach(({ category, count }) => {
|
|
||||||
stats[category] = count;
|
|
||||||
});
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}, [statistics]);
|
|
||||||
|
|
||||||
// 카테고리 이름 및 색상 가져오기
|
|
||||||
const getCategoryInfo = (categoryId: string) => {
|
|
||||||
const category = COMPONENT_CATEGORIES.find((cat) => cat.id === categoryId);
|
|
||||||
return category || { id: "other", name: "기타", color: "gray" };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 삭제 처리
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!selectedComponent) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteComponentMutation.mutateAsync(selectedComponent.component_code);
|
|
||||||
setShowDeleteModal(false);
|
|
||||||
setSelectedComponent(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("컴포넌트 삭제 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 생성 처리
|
|
||||||
const handleCreate = async (data: any) => {
|
|
||||||
await createComponentMutation.mutateAsync(data);
|
|
||||||
setShowNewComponentModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 수정 처리
|
|
||||||
const handleUpdate = async (data: any) => {
|
|
||||||
if (!selectedComponent) return;
|
|
||||||
await updateComponentMutation.mutateAsync({
|
|
||||||
component_code: selectedComponent.component_code,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
setShowEditComponentModal(false);
|
|
||||||
setSelectedComponent(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="mx-auto h-8 w-8 animate-spin text-gray-400" />
|
|
||||||
<p className="mt-2 text-sm text-gray-500">컴포넌트 목록을 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<Package className="mx-auto h-8 w-8 text-red-400" />
|
|
||||||
<p className="mt-2 text-sm text-red-600">컴포넌트 목록을 불러오는데 실패했습니다.</p>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-4">
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col p-6">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">컴포넌트 관리</h1>
|
|
||||||
<p className="text-sm text-gray-500">화면 설계에 사용되는 컴포넌트들을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button size="sm" onClick={() => setShowNewComponentModal(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />새 컴포넌트
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 카테고리 통계 */}
|
|
||||||
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
||||||
{COMPONENT_CATEGORIES.map((category) => {
|
|
||||||
const count = categoryStats[category.id] || 0;
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={category.id}
|
|
||||||
className="cursor-pointer hover:shadow-md"
|
|
||||||
onClick={() => setSelectedCategory(category.id)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4 text-center">
|
|
||||||
<div className={`mb-2 text-2xl font-bold text-${category.color}-600`}>{count}</div>
|
|
||||||
<div className="text-sm text-gray-600">{category.name}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 및 필터 */}
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-y-0 md:space-x-4">
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="컴포넌트 이름, 타입, 설명으로 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 카테고리 필터 */}
|
|
||||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<Filter className="mr-2 h-4 w-4" />
|
|
||||||
<SelectValue placeholder="카테고리" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체 카테고리</SelectItem>
|
|
||||||
{COMPONENT_CATEGORIES.map((category) => (
|
|
||||||
<SelectItem key={category.id} value={category.id}>
|
|
||||||
{category.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
|
||||||
<SelectTrigger className="w-32">
|
|
||||||
<SelectValue placeholder="정렬" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="sort_order">순서</SelectItem>
|
|
||||||
<SelectItem value="type_name">이름</SelectItem>
|
|
||||||
<SelectItem value="web_type">타입</SelectItem>
|
|
||||||
<SelectItem value="category">카테고리</SelectItem>
|
|
||||||
<SelectItem value="updated_date">수정일</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}>
|
|
||||||
{sortOrder === "asc" ? "↑" : "↓"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 컴포넌트 목록 테이블 */}
|
|
||||||
<Card className="flex-1">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span>컴포넌트 목록 ({components.length}개)</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>컴포넌트 이름</TableHead>
|
|
||||||
<TableHead>컴포넌트 코드</TableHead>
|
|
||||||
<TableHead>카테고리</TableHead>
|
|
||||||
<TableHead>타입</TableHead>
|
|
||||||
<TableHead>상태</TableHead>
|
|
||||||
<TableHead>수정일</TableHead>
|
|
||||||
<TableHead className="w-24">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{components.map((component) => {
|
|
||||||
const categoryInfo = getCategoryInfo(component.category || "other");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={component.component_code}>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{component.component_name}</div>
|
|
||||||
{component.component_name_eng && (
|
|
||||||
<div className="text-xs text-gray-500">{component.component_name_eng}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<code className="rounded bg-gray-100 px-2 py-1 text-xs">{component.component_code}</code>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`text-${categoryInfo.color}-600 border-${categoryInfo.color}-200`}
|
|
||||||
>
|
|
||||||
{categoryInfo.name}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{component.component_config ? (
|
|
||||||
<code className="text-xs text-blue-600">
|
|
||||||
{component.component_config.type || component.component_code}
|
|
||||||
</code>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400">없음</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={component.is_active === "Y" ? "default" : "secondary"}>
|
|
||||||
{component.is_active === "Y" ? "활성" : "비활성"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-gray-500">
|
|
||||||
{component.updated_date ? new Date(component.updated_date).toLocaleDateString() : "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedComponent(component);
|
|
||||||
setShowEditComponentModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedComponent(component);
|
|
||||||
setShowDeleteModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
|
||||||
<AlertModal
|
|
||||||
isOpen={showDeleteModal}
|
|
||||||
onClose={() => setShowDeleteModal(false)}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
type="warning"
|
|
||||||
title="컴포넌트 삭제"
|
|
||||||
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
|
|
||||||
confirmText="삭제"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 새 컴포넌트 추가 모달 */}
|
|
||||||
<ComponentFormModal
|
|
||||||
isOpen={showNewComponentModal}
|
|
||||||
onClose={() => setShowNewComponentModal(false)}
|
|
||||||
onSubmit={handleCreate}
|
|
||||||
mode="create"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 컴포넌트 편집 모달 */}
|
|
||||||
<ComponentFormModal
|
|
||||||
isOpen={showEditComponentModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowEditComponentModal(false);
|
|
||||||
setSelectedComponent(null);
|
|
||||||
}}
|
|
||||||
onSubmit={handleUpdate}
|
|
||||||
initialData={selectedComponent}
|
|
||||||
mode="edit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,565 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Plus, X, Save, RotateCcw, AlertTriangle, CheckCircle } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useComponentDuplicateCheck } from "@/hooks/admin/useComponentDuplicateCheck";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
// 컴포넌트 카테고리 정의
|
|
||||||
const COMPONENT_CATEGORIES = [
|
|
||||||
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
|
|
||||||
{ id: "action", name: "액션", description: "사용자 액션을 처리하는 컴포넌트" },
|
|
||||||
{ id: "display", name: "표시", description: "정보를 표시하는 컴포넌트" },
|
|
||||||
{ id: "layout", name: "레이아웃", description: "레이아웃을 구성하는 컴포넌트" },
|
|
||||||
{ id: "other", name: "기타", description: "기타 컴포넌트" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 컴포넌트 타입 정의
|
|
||||||
const COMPONENT_TYPES = [
|
|
||||||
{ id: "widget", name: "위젯", description: "입력 양식 위젯" },
|
|
||||||
{ id: "button", name: "버튼", description: "액션 버튼" },
|
|
||||||
{ id: "card", name: "카드", description: "카드 컨테이너" },
|
|
||||||
{ id: "container", name: "컨테이너", description: "일반 컨테이너" },
|
|
||||||
{ id: "dashboard", name: "대시보드", description: "대시보드 그리드" },
|
|
||||||
{ id: "alert", name: "알림", description: "알림 메시지" },
|
|
||||||
{ id: "badge", name: "배지", description: "상태 배지" },
|
|
||||||
{ id: "progress", name: "진행률", description: "진행률 표시" },
|
|
||||||
{ id: "chart", name: "차트", description: "데이터 차트" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 웹타입 정의 (위젯인 경우만)
|
|
||||||
const WEB_TYPES = [
|
|
||||||
"text",
|
|
||||||
"number",
|
|
||||||
"decimal",
|
|
||||||
"date",
|
|
||||||
"datetime",
|
|
||||||
"select",
|
|
||||||
"dropdown",
|
|
||||||
"textarea",
|
|
||||||
"boolean",
|
|
||||||
"checkbox",
|
|
||||||
"radio",
|
|
||||||
"code",
|
|
||||||
"entity",
|
|
||||||
"file",
|
|
||||||
"email",
|
|
||||||
"tel",
|
|
||||||
"color",
|
|
||||||
"range",
|
|
||||||
"time",
|
|
||||||
"week",
|
|
||||||
"month",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ComponentFormData {
|
|
||||||
component_code: string;
|
|
||||||
component_name: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
component_config: {
|
|
||||||
type: string;
|
|
||||||
webType?: string;
|
|
||||||
config_panel?: string;
|
|
||||||
};
|
|
||||||
default_size: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
icon_name: string;
|
|
||||||
active: string;
|
|
||||||
sort_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentFormModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: ComponentFormData) => Promise<void>;
|
|
||||||
initialData?: any;
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ComponentFormModal: React.FC<ComponentFormModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
initialData,
|
|
||||||
mode = "create",
|
|
||||||
}) => {
|
|
||||||
const [formData, setFormData] = useState<ComponentFormData>({
|
|
||||||
component_code: "",
|
|
||||||
component_name: "",
|
|
||||||
description: "",
|
|
||||||
category: "other",
|
|
||||||
component_config: {
|
|
||||||
type: "widget",
|
|
||||||
},
|
|
||||||
default_size: {
|
|
||||||
width: 200,
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
icon_name: "",
|
|
||||||
is_active: "Y",
|
|
||||||
sort_order: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [shouldCheckDuplicate, setShouldCheckDuplicate] = useState(false);
|
|
||||||
|
|
||||||
// 중복 체크 쿼리 (생성 모드에서만 활성화)
|
|
||||||
const duplicateCheck = useComponentDuplicateCheck(
|
|
||||||
formData.component_code,
|
|
||||||
mode === "create" && shouldCheckDuplicate && formData.component_code.length > 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 초기 데이터 설정
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
if (mode === "edit" && initialData) {
|
|
||||||
setFormData({
|
|
||||||
component_code: initialData.component_code || "",
|
|
||||||
component_name: initialData.component_name || "",
|
|
||||||
description: initialData.description || "",
|
|
||||||
category: initialData.category || "other",
|
|
||||||
component_config: initialData.component_config || { type: "widget" },
|
|
||||||
default_size: initialData.default_size || { width: 200, height: 40 },
|
|
||||||
icon_name: initialData.icon_name || "",
|
|
||||||
is_active: initialData.is_active || "Y",
|
|
||||||
sort_order: initialData.sort_order || 100,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 새 컴포넌트 생성 시 초기값
|
|
||||||
setFormData({
|
|
||||||
component_code: "",
|
|
||||||
component_name: "",
|
|
||||||
description: "",
|
|
||||||
category: "other",
|
|
||||||
component_config: {
|
|
||||||
type: "widget",
|
|
||||||
},
|
|
||||||
default_size: {
|
|
||||||
width: 200,
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
icon_name: "",
|
|
||||||
is_active: "Y",
|
|
||||||
sort_order: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, mode, initialData]);
|
|
||||||
|
|
||||||
// 컴포넌트 코드 자동 생성
|
|
||||||
const generateComponentCode = (name: string, type: string) => {
|
|
||||||
if (!name) return "";
|
|
||||||
|
|
||||||
// 한글을 영문으로 매핑
|
|
||||||
const koreanToEnglish: { [key: string]: string } = {
|
|
||||||
도움말: "help",
|
|
||||||
툴팁: "tooltip",
|
|
||||||
안내: "guide",
|
|
||||||
알림: "alert",
|
|
||||||
버튼: "button",
|
|
||||||
카드: "card",
|
|
||||||
대시보드: "dashboard",
|
|
||||||
패널: "panel",
|
|
||||||
입력: "input",
|
|
||||||
텍스트: "text",
|
|
||||||
선택: "select",
|
|
||||||
체크: "check",
|
|
||||||
라디오: "radio",
|
|
||||||
파일: "file",
|
|
||||||
이미지: "image",
|
|
||||||
테이블: "table",
|
|
||||||
리스트: "list",
|
|
||||||
폼: "form",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 한글을 영문으로 변환
|
|
||||||
let englishName = name;
|
|
||||||
Object.entries(koreanToEnglish).forEach(([korean, english]) => {
|
|
||||||
englishName = englishName.replace(new RegExp(korean, "g"), english);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cleanName = englishName
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s]/g, "")
|
|
||||||
.replace(/\s+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
|
|
||||||
// 빈 문자열이거나 숫자로 시작하는 경우 기본값 설정
|
|
||||||
const finalName = cleanName || "component";
|
|
||||||
const validName = /^[0-9]/.test(finalName) ? `comp-${finalName}` : finalName;
|
|
||||||
|
|
||||||
return type === "widget" ? validName : `${validName}-${type}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 필드 변경 처리
|
|
||||||
const handleChange = (field: string, value: any) => {
|
|
||||||
setFormData((prev) => {
|
|
||||||
const newData = { ...prev };
|
|
||||||
|
|
||||||
if (field.includes(".")) {
|
|
||||||
const [parent, child] = field.split(".");
|
|
||||||
newData[parent as keyof ComponentFormData] = {
|
|
||||||
...(newData[parent as keyof ComponentFormData] as any),
|
|
||||||
[child]: value,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
(newData as any)[field] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 이름이 변경되면 코드 자동 생성
|
|
||||||
if (field === "component_name" || field === "component_config.type") {
|
|
||||||
const name = field === "component_name" ? value : newData.component_name;
|
|
||||||
const type = field === "component_config.type" ? value : newData.component_config.type;
|
|
||||||
|
|
||||||
if (name && mode === "create") {
|
|
||||||
newData.component_code = generateComponentCode(name, type);
|
|
||||||
// 자동 생성된 코드에 대해서도 중복 체크 활성화
|
|
||||||
setShouldCheckDuplicate(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 코드가 직접 변경되면 중복 체크 활성화
|
|
||||||
if (field === "component_code" && mode === "create") {
|
|
||||||
setShouldCheckDuplicate(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 제출
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
// 유효성 검사
|
|
||||||
if (!formData.component_code || !formData.component_name) {
|
|
||||||
toast.error("컴포넌트 코드와 이름은 필수입니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.component_config.type) {
|
|
||||||
toast.error("컴포넌트 타입을 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 생성 모드에서 중복 체크
|
|
||||||
if (mode === "create" && duplicateCheck.data?.isDuplicate) {
|
|
||||||
toast.error("이미 사용 중인 컴포넌트 코드입니다. 다른 코드를 사용해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await onSubmit(formData);
|
|
||||||
toast.success(mode === "create" ? "컴포넌트가 생성되었습니다." : "컴포넌트가 수정되었습니다.");
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(mode === "create" ? "컴포넌트 생성에 실패했습니다." : "컴포넌트 수정에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
const handleReset = () => {
|
|
||||||
if (mode === "edit" && initialData) {
|
|
||||||
setFormData({
|
|
||||||
component_code: initialData.component_code || "",
|
|
||||||
component_name: initialData.component_name || "",
|
|
||||||
description: initialData.description || "",
|
|
||||||
category: initialData.category || "other",
|
|
||||||
component_config: initialData.component_config || { type: "widget" },
|
|
||||||
default_size: initialData.default_size || { width: 200, height: 40 },
|
|
||||||
icon_name: initialData.icon_name || "",
|
|
||||||
is_active: initialData.is_active || "Y",
|
|
||||||
sort_order: initialData.sort_order || 100,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setFormData({
|
|
||||||
component_code: "",
|
|
||||||
component_name: "",
|
|
||||||
description: "",
|
|
||||||
category: "other",
|
|
||||||
component_config: {
|
|
||||||
type: "widget",
|
|
||||||
},
|
|
||||||
default_size: {
|
|
||||||
width: 200,
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
icon_name: "",
|
|
||||||
is_active: "Y",
|
|
||||||
sort_order: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{mode === "create" ? "새 컴포넌트 추가" : "컴포넌트 편집"}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{mode === "create"
|
|
||||||
? "화면 설계에 사용할 새로운 컴포넌트를 추가합니다."
|
|
||||||
: "선택한 컴포넌트의 정보를 수정합니다."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
|
||||||
{/* 기본 정보 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="component_name">컴포넌트 이름 *</Label>
|
|
||||||
<Input
|
|
||||||
id="component_name"
|
|
||||||
value={formData.component_name}
|
|
||||||
onChange={(e) => handleChange("component_name", e.target.value)}
|
|
||||||
placeholder="예: 정보 알림"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="component_code">컴포넌트 코드 *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="component_code"
|
|
||||||
value={formData.component_code}
|
|
||||||
onChange={(e) => handleChange("component_code", e.target.value)}
|
|
||||||
placeholder="예: alert-info"
|
|
||||||
disabled={mode === "edit"}
|
|
||||||
className={
|
|
||||||
mode === "create" && duplicateCheck.data?.isDuplicate
|
|
||||||
? "border-red-500 pr-10"
|
|
||||||
: mode === "create" && duplicateCheck.data && !duplicateCheck.data.isDuplicate
|
|
||||||
? "border-green-500 pr-10"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{mode === "create" && formData.component_code && duplicateCheck.data && (
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
||||||
{duplicateCheck.data.isDuplicate ? (
|
|
||||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{mode === "create" && formData.component_code && duplicateCheck.data && (
|
|
||||||
<Alert
|
|
||||||
className={`mt-2 ${duplicateCheck.data.isDuplicate ? "border-red-200 bg-red-50" : "border-green-200 bg-green-50"}`}
|
|
||||||
>
|
|
||||||
<AlertDescription className={duplicateCheck.data.isDuplicate ? "text-red-700" : "text-green-700"}>
|
|
||||||
{duplicateCheck.data.isDuplicate
|
|
||||||
? "⚠️ 이미 사용 중인 컴포넌트 코드입니다."
|
|
||||||
: "✅ 사용 가능한 컴포넌트 코드입니다."}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">설명</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => handleChange("description", e.target.value)}
|
|
||||||
placeholder="컴포넌트에 대한 설명을 입력하세요"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label>카테고리</Label>
|
|
||||||
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{COMPONENT_CATEGORIES.map((category) => (
|
|
||||||
<SelectItem key={category.id} value={category.id}>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{category.name}</div>
|
|
||||||
<div className="text-xs text-gray-500">{category.description}</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="icon_name">아이콘 이름</Label>
|
|
||||||
<Input
|
|
||||||
id="icon_name"
|
|
||||||
value={formData.icon_name}
|
|
||||||
onChange={(e) => handleChange("icon_name", e.target.value)}
|
|
||||||
placeholder="예: info, alert-triangle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 컴포넌트 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">컴포넌트 설정</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>컴포넌트 타입</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.component_config.type}
|
|
||||||
onValueChange={(value) => handleChange("component_config.type", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{COMPONENT_TYPES.map((type) => (
|
|
||||||
<SelectItem key={type.id} value={type.id}>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{type.name}</div>
|
|
||||||
<div className="text-xs text-gray-500">{type.description}</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 위젯인 경우 웹타입 선택 */}
|
|
||||||
{formData.component_config.type === "widget" && (
|
|
||||||
<div>
|
|
||||||
<Label>웹타입</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.component_config.webType || ""}
|
|
||||||
onValueChange={(value) => handleChange("component_config.webType", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="웹타입을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{WEB_TYPES.map((webType) => (
|
|
||||||
<SelectItem key={webType} value={webType}>
|
|
||||||
{webType}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="config_panel">설정 패널</Label>
|
|
||||||
<Input
|
|
||||||
id="config_panel"
|
|
||||||
value={formData.component_config.config_panel || ""}
|
|
||||||
onChange={(e) => handleChange("component_config.config_panel", e.target.value)}
|
|
||||||
placeholder="예: AlertConfigPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 기본 크기 및 기타 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">기본 크기 및 기타</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="width">기본 너비 (px)</Label>
|
|
||||||
<Input
|
|
||||||
id="width"
|
|
||||||
type="number"
|
|
||||||
value={formData.default_size.width}
|
|
||||||
onChange={(e) => handleChange("default_size.width", parseInt(e.target.value))}
|
|
||||||
min="1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="height">기본 높이 (px)</Label>
|
|
||||||
<Input
|
|
||||||
id="height"
|
|
||||||
type="number"
|
|
||||||
value={formData.default_size.height}
|
|
||||||
onChange={(e) => handleChange("default_size.height", parseInt(e.target.value))}
|
|
||||||
min="1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
|
||||||
<Input
|
|
||||||
id="sort_order"
|
|
||||||
type="number"
|
|
||||||
value={formData.sort_order}
|
|
||||||
onChange={(e) => handleChange("sort_order", parseInt(e.target.value))}
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="active"
|
|
||||||
checked={formData.is_active === "Y"}
|
|
||||||
onCheckedChange={(checked) => handleChange("is_active", checked ? "Y" : "N")}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="active">활성화</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleReset} disabled={isSubmitting}>
|
|
||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || (mode === "create" && duplicateCheck.data?.isDuplicate)}
|
|
||||||
>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
{isSubmitting ? "저장 중..." : mode === "create" ? "생성" : "수정"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -343,6 +343,37 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
break;
|
break;
|
||||||
case "data-save":
|
case "data-save":
|
||||||
settings = dataSaveSettings;
|
settings = dataSaveSettings;
|
||||||
|
|
||||||
|
// INSERT가 아닌 액션 타입에 대한 실행조건 필수 검증
|
||||||
|
for (const action of dataSaveSettings.actions) {
|
||||||
|
if (action.actionType !== "insert") {
|
||||||
|
if (!action.conditions || action.conditions.length === 0) {
|
||||||
|
toast.error(
|
||||||
|
`${action.actionType.toUpperCase()} 액션은 실행조건이 필수입니다. '${action.name}' 액션에 실행조건을 추가해주세요.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외)
|
||||||
|
const hasValidConditions = action.conditions.some((condition) => {
|
||||||
|
if (condition.type !== "condition") return false;
|
||||||
|
if (!condition.field || !condition.operator) return false;
|
||||||
|
|
||||||
|
// value가 null, undefined, 빈 문자열이면 유효하지 않음
|
||||||
|
const value = condition.value;
|
||||||
|
if (value === null || value === undefined || value === "") return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasValidConditions) {
|
||||||
|
toast.error(
|
||||||
|
`${action.actionType.toUpperCase()} 액션은 완전한 실행조건이 필요합니다. '${action.name}' 액션에 필드, 연산자, 값을 모두 설정해주세요.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "external-call":
|
case "external-call":
|
||||||
// 외부 호출은 plan에 저장
|
// 외부 호출은 plan에 저장
|
||||||
|
|
@ -508,9 +539,43 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
case "data-save":
|
case "data-save":
|
||||||
// 데이터 저장: 액션과 필드 매핑이 완성되어야 함
|
// 데이터 저장: 액션과 필드 매핑이 완성되어야 함
|
||||||
const hasActions = dataSaveSettings.actions.length > 0;
|
const hasActions = dataSaveSettings.actions.length > 0;
|
||||||
const allActionsHaveMappings = dataSaveSettings.actions.every((action) => action.fieldMappings.length > 0);
|
|
||||||
const allMappingsComplete = dataSaveSettings.actions.every((action) =>
|
// DELETE 액션은 필드 매핑이 필요 없음
|
||||||
action.fieldMappings.every((mapping) => {
|
const allActionsHaveMappings = dataSaveSettings.actions.every((action) => {
|
||||||
|
if (action.actionType === "delete") {
|
||||||
|
return true; // DELETE는 필드 매핑 불필요
|
||||||
|
}
|
||||||
|
return action.fieldMappings.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMappingsComplete = dataSaveSettings.actions.every((action) => {
|
||||||
|
if (action.actionType === "delete") {
|
||||||
|
return true; // DELETE는 필드 매핑 검증 생략
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT 액션의 경우 모든 TO 테이블 컬럼이 매핑되거나 기본값이 있어야 함
|
||||||
|
if (action.actionType === "insert") {
|
||||||
|
// TO 테이블의 모든 컬럼을 찾기
|
||||||
|
const toTableName = action.fieldMappings[0]?.targetTable;
|
||||||
|
if (!toTableName) return false;
|
||||||
|
|
||||||
|
const toTableColumns = tableColumnsCache[toTableName] || [];
|
||||||
|
if (toTableColumns.length === 0) return false;
|
||||||
|
|
||||||
|
// 모든 TO 컬럼이 매핑되거나 기본값이 있는지 확인
|
||||||
|
return toTableColumns.every((column) => {
|
||||||
|
const mapping = action.fieldMappings.find((m) => m.targetField === column.columnName);
|
||||||
|
if (!mapping) return false;
|
||||||
|
|
||||||
|
// 소스 매핑 또는 기본값 중 하나는 있어야 함
|
||||||
|
const hasSource = mapping.sourceTable && mapping.sourceField;
|
||||||
|
const hasDefault = mapping.defaultValue && mapping.defaultValue.trim();
|
||||||
|
|
||||||
|
return hasSource || hasDefault;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return action.fieldMappings.every((mapping) => {
|
||||||
// 타겟은 항상 필요
|
// 타겟은 항상 필요
|
||||||
if (!mapping.targetTable || !mapping.targetField) return false;
|
if (!mapping.targetTable || !mapping.targetField) return false;
|
||||||
|
|
||||||
|
|
@ -525,9 +590,36 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
|
|
||||||
// FROM 테이블이 있으면 소스 매핑 완성 또는 기본값 필요
|
// FROM 테이블이 있으면 소스 매핑 완성 또는 기본값 필요
|
||||||
return hasSource || hasDefault;
|
return hasSource || hasDefault;
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
|
|
||||||
|
// INSERT가 아닌 액션 타입에 대한 실행조건 필수 검증
|
||||||
|
const allRequiredConditionsMet = dataSaveSettings.actions.every((action) => {
|
||||||
|
if (action.actionType === "insert") {
|
||||||
|
return true; // INSERT는 조건 불필요
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT가 아닌 액션은 유효한 조건이 있어야 함
|
||||||
|
if (!action.conditions || action.conditions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외)
|
||||||
|
const hasValidConditions = action.conditions.some((condition) => {
|
||||||
|
if (condition.type !== "condition") return false;
|
||||||
|
if (!condition.field || !condition.operator) return false;
|
||||||
|
|
||||||
|
// value가 null, undefined, 빈 문자열이면 유효하지 않음
|
||||||
|
const value = condition.value;
|
||||||
|
if (value === null || value === undefined || value === "") return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasValidConditions;
|
||||||
|
});
|
||||||
|
|
||||||
|
return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet;
|
||||||
|
|
||||||
case "external-call":
|
case "external-call":
|
||||||
// 외부 호출: 설정 ID와 메시지가 있어야 함
|
// 외부 호출: 설정 ID와 메시지가 있어야 함
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,22 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
||||||
}) => {
|
}) => {
|
||||||
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
||||||
|
|
||||||
|
// INSERT가 아닌 액션 타입인지 확인
|
||||||
|
const isConditionRequired = action.actionType !== "insert";
|
||||||
|
|
||||||
|
// 유효한 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외)
|
||||||
|
const hasValidConditions =
|
||||||
|
action.conditions?.some((condition) => {
|
||||||
|
if (condition.type !== "condition") return false;
|
||||||
|
if (!condition.field || !condition.operator) return false;
|
||||||
|
|
||||||
|
// value가 null, undefined, 빈 문자열이면 유효하지 않음
|
||||||
|
const value = condition.value;
|
||||||
|
if (value === null || value === undefined || value === "") return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}) || false;
|
||||||
|
|
||||||
const addActionCondition = () => {
|
const addActionCondition = () => {
|
||||||
const newActions = [...settings.actions];
|
const newActions = [...settings.actions];
|
||||||
if (!newActions[actionIndex].conditions) {
|
if (!newActions[actionIndex].conditions) {
|
||||||
|
|
@ -65,14 +81,28 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
|
<summary
|
||||||
|
className={`flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium hover:bg-gray-50 hover:text-gray-900 ${
|
||||||
|
isConditionRequired && !hasValidConditions
|
||||||
|
? "border-red-300 bg-red-50 text-red-700"
|
||||||
|
: "border-gray-200 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
🔍 이 액션의 실행 조건 (선택사항)
|
🔍 이 액션의 실행 조건
|
||||||
|
{isConditionRequired ? (
|
||||||
|
<span className="rounded bg-red-100 px-1 py-0.5 text-xs font-semibold text-red-700">필수</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">(선택사항)</span>
|
||||||
|
)}
|
||||||
{action.conditions && action.conditions.length > 0 && (
|
{action.conditions && action.conditions.length > 0 && (
|
||||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||||
{action.conditions.length}개
|
{action.conditions.length}개
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isConditionRequired && !hasValidConditions && (
|
||||||
|
<span className="rounded bg-red-100 px-1 py-0.5 text-xs text-red-600">⚠️ 조건 필요</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{action.conditions && action.conditions.length > 0 && (
|
{action.conditions && action.conditions.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -115,6 +145,36 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 조건이 없을 때 안내 메시지 */}
|
||||||
|
{(!action.conditions || action.conditions.length === 0) && (
|
||||||
|
<div
|
||||||
|
className={`rounded border p-3 text-xs ${
|
||||||
|
isConditionRequired
|
||||||
|
? "border-red-200 bg-red-50 text-red-700"
|
||||||
|
: "border-gray-200 bg-gray-50 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isConditionRequired ? (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-red-500">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">실행조건이 필요합니다</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{action.actionType.toUpperCase()} 액션은 언제 실행될지 결정하는 조건이 반드시 필요합니다.
|
||||||
|
<br />
|
||||||
|
<strong>필드, 연산자, 값</strong>을 모두 입력해야 합니다.
|
||||||
|
<br />
|
||||||
|
예: user_name = '관리자우저' AND status = 'active'
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>조건이 설정되지 않았습니다. INSERT 액션은 조건 없이도 실행 가능합니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{action.conditions && action.conditions.length > 0 && (
|
{action.conditions && action.conditions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{action.conditions.map((condition, condIndex) => (
|
{action.conditions.map((condition, condIndex) => (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel";
|
||||||
|
|
||||||
interface ActionFieldMappingsProps {
|
interface ActionFieldMappingsProps {
|
||||||
action: DataSaveSettings["actions"][0];
|
action: DataSaveSettings["actions"][0];
|
||||||
|
|
@ -16,6 +17,10 @@ interface ActionFieldMappingsProps {
|
||||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
availableTables: TableInfo[];
|
availableTables: TableInfo[];
|
||||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||||
|
fromTableColumns?: ColumnInfo[];
|
||||||
|
toTableColumns?: ColumnInfo[];
|
||||||
|
fromTableName?: string;
|
||||||
|
toTableName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
|
|
@ -25,7 +30,26 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
availableTables,
|
availableTables,
|
||||||
tableColumnsCache,
|
tableColumnsCache,
|
||||||
|
fromTableColumns = [],
|
||||||
|
toTableColumns = [],
|
||||||
|
fromTableName,
|
||||||
|
toTableName,
|
||||||
}) => {
|
}) => {
|
||||||
|
// INSERT 액션일 때는 새로운 패널 사용
|
||||||
|
if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) {
|
||||||
|
return (
|
||||||
|
<InsertFieldMappingPanel
|
||||||
|
action={action}
|
||||||
|
actionIndex={actionIndex}
|
||||||
|
settings={settings}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
fromTableColumns={fromTableColumns}
|
||||||
|
toTableColumns={toTableColumns}
|
||||||
|
fromTableName={fromTableName}
|
||||||
|
toTableName={toTableName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
const addFieldMapping = () => {
|
const addFieldMapping = () => {
|
||||||
const newActions = [...settings.actions];
|
const newActions = [...settings.actions];
|
||||||
newActions[actionIndex].fieldMappings.push({
|
newActions[actionIndex].fieldMappings.push({
|
||||||
|
|
@ -54,7 +78,10 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||||
|
<span className="text-xs text-red-600">(필수)</span>
|
||||||
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
|
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
|
||||||
<Plus className="mr-1 h-2 w-2" />
|
<Plus className="mr-1 h-2 w-2" />
|
||||||
매핑 추가
|
매핑 추가
|
||||||
|
|
@ -208,6 +235,22 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 필드 매핑이 없을 때 안내 메시지 */}
|
||||||
|
{action.fieldMappings.length === 0 && (
|
||||||
|
<div className="rounded border border-red-200 bg-red-50 p-3 text-xs text-red-700">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-red-500">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">필드 매핑이 필요합니다</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{action.actionType.toUpperCase()} 액션은 어떤 데이터를 어떻게 처리할지 결정하는 필드 매핑이
|
||||||
|
필요합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,334 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
||||||
|
|
||||||
|
interface ColumnMapping {
|
||||||
|
toColumnName: string;
|
||||||
|
fromColumnName?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnTableSectionProps {
|
||||||
|
type: "from" | "to";
|
||||||
|
tableName: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
selectedColumn: string | null;
|
||||||
|
onColumnClick: (columnName: string) => void;
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (term: string) => void;
|
||||||
|
dataTypeFilter: string;
|
||||||
|
onDataTypeFilterChange: (filter: string) => void;
|
||||||
|
showMappedOnly: boolean;
|
||||||
|
onShowMappedOnlyChange: (show: boolean) => void;
|
||||||
|
showUnmappedOnly: boolean;
|
||||||
|
onShowUnmappedOnlyChange: (show: boolean) => void;
|
||||||
|
columnMappings: ColumnMapping[];
|
||||||
|
onDefaultValueChange?: (columnName: string, value: string) => void;
|
||||||
|
onRemoveMapping?: (columnName: string) => void;
|
||||||
|
isColumnClickable: (column: ColumnInfo) => boolean;
|
||||||
|
oppositeSelectedColumn?: string | null;
|
||||||
|
oppositeColumns?: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
||||||
|
type,
|
||||||
|
tableName,
|
||||||
|
columns,
|
||||||
|
selectedColumn,
|
||||||
|
onColumnClick,
|
||||||
|
searchTerm,
|
||||||
|
onSearchChange,
|
||||||
|
dataTypeFilter,
|
||||||
|
onDataTypeFilterChange,
|
||||||
|
showMappedOnly,
|
||||||
|
onShowMappedOnlyChange,
|
||||||
|
showUnmappedOnly,
|
||||||
|
onShowUnmappedOnlyChange,
|
||||||
|
columnMappings,
|
||||||
|
onDefaultValueChange,
|
||||||
|
onRemoveMapping,
|
||||||
|
isColumnClickable,
|
||||||
|
oppositeSelectedColumn,
|
||||||
|
oppositeColumns,
|
||||||
|
}) => {
|
||||||
|
const isFromTable = type === "from";
|
||||||
|
|
||||||
|
// 데이터 타입 목록 추출
|
||||||
|
const dataTypes = useMemo(() => {
|
||||||
|
const types = new Set(columns.map((col) => col.dataType).filter((type): type is string => !!type));
|
||||||
|
return Array.from(types).sort();
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
// 필터링된 컬럼 목록
|
||||||
|
const filteredColumns = useMemo(() => {
|
||||||
|
return columns.filter((column) => {
|
||||||
|
// 검색어 필터
|
||||||
|
const matchesSearch = searchTerm === "" || column.columnName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
// 데이터 타입 필터
|
||||||
|
const matchesDataType = dataTypeFilter === "" || column.dataType === dataTypeFilter;
|
||||||
|
|
||||||
|
// 매핑 상태 필터
|
||||||
|
const isMapped = isFromTable
|
||||||
|
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
|
||||||
|
: (() => {
|
||||||
|
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
|
||||||
|
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
||||||
|
})();
|
||||||
|
|
||||||
|
const matchesMappingFilter =
|
||||||
|
(!showMappedOnly && !showUnmappedOnly) || (showMappedOnly && isMapped) || (showUnmappedOnly && !isMapped);
|
||||||
|
|
||||||
|
return matchesSearch && matchesDataType && matchesMappingFilter;
|
||||||
|
});
|
||||||
|
}, [columns, searchTerm, dataTypeFilter, showMappedOnly, showUnmappedOnly, columnMappings, isFromTable]);
|
||||||
|
|
||||||
|
const mappedCount = columns.filter((column) =>
|
||||||
|
isFromTable
|
||||||
|
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
|
||||||
|
: (() => {
|
||||||
|
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
|
||||||
|
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
||||||
|
})(),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="rounded-t-lg bg-gray-600 px-4 py-3 text-white">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
{isFromTable ? "From" : "To"}: {tableName} ({columns.length}/{columns.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<div className="border-r border-l border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="컬럼명 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
value={dataTypeFilter || "all"}
|
||||||
|
onValueChange={(value) => onDataTypeFilterChange(value === "all" ? "" : value || "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-full text-xs" size="sm">
|
||||||
|
<SelectValue placeholder="모든 타입" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">모든 타입</SelectItem>
|
||||||
|
{dataTypes.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex cursor-pointer gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onShowMappedOnlyChange(!showMappedOnly);
|
||||||
|
if (showUnmappedOnly) onShowUnmappedOnlyChange(false);
|
||||||
|
}}
|
||||||
|
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
|
||||||
|
showMappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
설정됨
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onShowUnmappedOnlyChange(!showUnmappedOnly);
|
||||||
|
if (showMappedOnly) onShowMappedOnlyChange(false);
|
||||||
|
}}
|
||||||
|
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
|
||||||
|
showUnmappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
미설정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 리스트 */}
|
||||||
|
<div className="h-96 overflow-y-auto border-r border-l border-gray-200">
|
||||||
|
{filteredColumns.map((column) => {
|
||||||
|
const isSelected = selectedColumn === column.columnName;
|
||||||
|
const isClickable = isColumnClickable(column);
|
||||||
|
|
||||||
|
if (isFromTable) {
|
||||||
|
// FROM 테이블 렌더링
|
||||||
|
const isMapped = columnMappings.some((mapping) => mapping.fromColumnName === column.columnName);
|
||||||
|
const mappedToColumn = columnMappings.find(
|
||||||
|
(mapping) => mapping.fromColumnName === column.columnName,
|
||||||
|
)?.toColumnName;
|
||||||
|
|
||||||
|
// 선택된 TO 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
|
||||||
|
const isTypeCompatible =
|
||||||
|
!oppositeSelectedColumn ||
|
||||||
|
isMapped ||
|
||||||
|
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.columnName}
|
||||||
|
onClick={isClickable ? () => onColumnClick(column.columnName) : undefined}
|
||||||
|
className={`border-b border-gray-200 px-3 py-2 text-xs transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-gray-200 text-gray-800"
|
||||||
|
: isMapped
|
||||||
|
? "bg-gray-100 text-gray-700"
|
||||||
|
: oppositeSelectedColumn && !isTypeCompatible
|
||||||
|
? "cursor-not-allowed bg-red-50 text-red-400 opacity-60"
|
||||||
|
: isClickable
|
||||||
|
? "cursor-pointer hover:bg-gray-50"
|
||||||
|
: "cursor-not-allowed bg-gray-100 text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-medium">{column.columnName}</span>
|
||||||
|
{isSelected && <span className="flex-shrink-0 text-blue-500">●</span>}
|
||||||
|
{isMapped && <span className="flex-shrink-0 text-green-500">✓</span>}
|
||||||
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||||
|
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{column.dataType}
|
||||||
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||||
|
<span className="ml-1 text-red-400">(호환 불가)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isMapped && mappedToColumn && (
|
||||||
|
<div className="mt-1 truncate text-xs text-green-600">→ {mappedToColumn}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// TO 테이블 렌더링
|
||||||
|
const mapping = columnMappings.find((m) => m.toColumnName === column.columnName);
|
||||||
|
const isMapped = !!mapping?.fromColumnName;
|
||||||
|
const hasDefaultValue = !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
||||||
|
|
||||||
|
// 선택된 FROM 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
|
||||||
|
const isTypeCompatible =
|
||||||
|
!oppositeSelectedColumn ||
|
||||||
|
isMapped ||
|
||||||
|
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.columnName}
|
||||||
|
className={`border-b border-gray-200 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-gray-200"
|
||||||
|
: isMapped
|
||||||
|
? "bg-gray-100"
|
||||||
|
: hasDefaultValue
|
||||||
|
? "bg-gray-100"
|
||||||
|
: oppositeSelectedColumn && !isTypeCompatible
|
||||||
|
? "bg-red-50 opacity-60"
|
||||||
|
: "bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 컬럼 정보 행 */}
|
||||||
|
<div
|
||||||
|
onClick={isClickable && isTypeCompatible ? () => onColumnClick(column.columnName) : undefined}
|
||||||
|
className={`px-3 py-2 text-xs ${
|
||||||
|
isClickable && isTypeCompatible
|
||||||
|
? "cursor-pointer hover:bg-gray-50"
|
||||||
|
: oppositeSelectedColumn && !isTypeCompatible
|
||||||
|
? "cursor-not-allowed"
|
||||||
|
: hasDefaultValue
|
||||||
|
? "cursor-not-allowed"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-medium">{column.columnName}</span>
|
||||||
|
{isSelected && <span className="flex-shrink-0 text-green-500">●</span>}
|
||||||
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||||
|
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{column.dataType}
|
||||||
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||||
|
<span className="ml-1 text-red-400">(호환 불가)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMapped && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="truncate text-xs text-blue-600">← {mapping.fromColumnName}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveMapping?.(column.columnName);
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 text-red-500 hover:text-red-700"
|
||||||
|
title="매핑 제거"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMapped && onDefaultValueChange && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Input
|
||||||
|
type={getInputTypeForDataType(column.dataType?.toLowerCase() || "string")}
|
||||||
|
placeholder="기본값 입력..."
|
||||||
|
value={mapping?.defaultValue || ""}
|
||||||
|
onChange={(e) => onDefaultValueChange(column.columnName, e.target.value)}
|
||||||
|
className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
disabled={isSelected || !!oppositeSelectedColumn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 통계 */}
|
||||||
|
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
표시: {filteredColumns.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -135,7 +135,8 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
toTableName={toTableName}
|
toTableName={toTableName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 데이터 분할 설정 */}
|
{/* 데이터 분할 설정 - DELETE 액션은 제외 */}
|
||||||
|
{action.actionType !== "delete" && (
|
||||||
<ActionSplitConfig
|
<ActionSplitConfig
|
||||||
action={action}
|
action={action}
|
||||||
actionIndex={actionIndex}
|
actionIndex={actionIndex}
|
||||||
|
|
@ -144,8 +145,10 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
fromTableColumns={fromTableColumns}
|
fromTableColumns={fromTableColumns}
|
||||||
toTableColumns={toTableColumns}
|
toTableColumns={toTableColumns}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 필드 매핑 */}
|
{/* 필드 매핑 - DELETE 액션은 제외 */}
|
||||||
|
{action.actionType !== "delete" && (
|
||||||
<ActionFieldMappings
|
<ActionFieldMappings
|
||||||
action={action}
|
action={action}
|
||||||
actionIndex={actionIndex}
|
actionIndex={actionIndex}
|
||||||
|
|
@ -153,7 +156,35 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
availableTables={availableTables}
|
availableTables={availableTables}
|
||||||
tableColumnsCache={tableColumnsCache}
|
tableColumnsCache={tableColumnsCache}
|
||||||
|
fromTableColumns={fromTableColumns}
|
||||||
|
toTableColumns={toTableColumns}
|
||||||
|
fromTableName={fromTableName}
|
||||||
|
toTableName={toTableName}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DELETE 액션일 때 안내 메시지 */}
|
||||||
|
{action.actionType === "delete" && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span>ℹ️</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">DELETE 액션 정보</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
DELETE 액션은 <strong>실행조건만</strong> 필요합니다.
|
||||||
|
<br />
|
||||||
|
• 데이터 분할 설정: 불필요 (삭제 작업에는 분할이 의미 없음)
|
||||||
|
<br />
|
||||||
|
• 필드 매핑: 불필요 (조건에 맞는 데이터를 통째로 삭제)
|
||||||
|
<br />
|
||||||
|
위에서 설정한 실행조건에 맞는 모든 데이터가 삭제됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||||
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
import { ColumnTableSection } from "./ColumnTableSection";
|
||||||
|
|
||||||
|
interface InsertFieldMappingPanelProps {
|
||||||
|
action: DataSaveSettings["actions"][0];
|
||||||
|
actionIndex: number;
|
||||||
|
settings: DataSaveSettings;
|
||||||
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
|
fromTableColumns: ColumnInfo[];
|
||||||
|
toTableColumns: ColumnInfo[];
|
||||||
|
fromTableName?: string;
|
||||||
|
toTableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnMapping {
|
||||||
|
toColumnName: string;
|
||||||
|
fromColumnName?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = ({
|
||||||
|
action,
|
||||||
|
actionIndex,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
fromTableColumns,
|
||||||
|
toTableColumns,
|
||||||
|
fromTableName,
|
||||||
|
toTableName,
|
||||||
|
}) => {
|
||||||
|
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
|
||||||
|
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
|
||||||
|
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||||
|
|
||||||
|
// 검색 및 필터링 상태 (FROM과 TO 독립적)
|
||||||
|
const [fromSearchTerm, setFromSearchTerm] = useState("");
|
||||||
|
const [toSearchTerm, setToSearchTerm] = useState("");
|
||||||
|
const [fromDataTypeFilter, setFromDataTypeFilter] = useState("");
|
||||||
|
const [toDataTypeFilter, setToDataTypeFilter] = useState("");
|
||||||
|
|
||||||
|
// FROM 테이블 필터
|
||||||
|
const [fromShowMappedOnly, setFromShowMappedOnly] = useState(false);
|
||||||
|
const [fromShowUnmappedOnly, setFromShowUnmappedOnly] = useState(false);
|
||||||
|
|
||||||
|
// TO 테이블 필터
|
||||||
|
const [toShowMappedOnly, setToShowMappedOnly] = useState(false);
|
||||||
|
const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false);
|
||||||
|
|
||||||
|
// 기존 매핑 데이터를 columnMappings로 변환
|
||||||
|
useEffect(() => {
|
||||||
|
const mappings: ColumnMapping[] = toTableColumns.map((toCol) => {
|
||||||
|
const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toColumnName: toCol.columnName,
|
||||||
|
fromColumnName: existingMapping?.sourceField || undefined,
|
||||||
|
defaultValue: existingMapping?.defaultValue || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnMappings(mappings);
|
||||||
|
}, [action.fieldMappings, toTableColumns]);
|
||||||
|
|
||||||
|
// columnMappings 변경 시 settings 업데이트
|
||||||
|
const updateSettings = (newMappings: ColumnMapping[]) => {
|
||||||
|
const newActions = [...settings.actions];
|
||||||
|
|
||||||
|
// 새로운 fieldMappings 생성
|
||||||
|
const fieldMappings = newMappings
|
||||||
|
.filter((mapping) => mapping.fromColumnName || (mapping.defaultValue && mapping.defaultValue.trim()))
|
||||||
|
.map((mapping) => ({
|
||||||
|
sourceTable: mapping.fromColumnName ? fromTableName || "" : "",
|
||||||
|
sourceField: mapping.fromColumnName || "",
|
||||||
|
targetTable: toTableName || "",
|
||||||
|
targetField: mapping.toColumnName,
|
||||||
|
defaultValue: mapping.defaultValue || "",
|
||||||
|
transformFunction: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
newActions[actionIndex].fieldMappings = fieldMappings;
|
||||||
|
onSettingsChange({ ...settings, actions: newActions });
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 컬럼 클릭 핸들러
|
||||||
|
const handleFromColumnClick = (columnName: string) => {
|
||||||
|
if (selectedFromColumn === columnName) {
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
} else {
|
||||||
|
setSelectedFromColumn(columnName);
|
||||||
|
|
||||||
|
// TO 컬럼이 이미 선택되어 있으면 매핑 시도
|
||||||
|
if (selectedToColumn) {
|
||||||
|
const fromColumn = fromTableColumns.find((col) => col.columnName === columnName);
|
||||||
|
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
|
||||||
|
|
||||||
|
if (fromColumn && toColumn) {
|
||||||
|
// 데이터 타입 호환성 체크
|
||||||
|
if (fromColumn.dataType !== toColumn.dataType) {
|
||||||
|
alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 생성
|
||||||
|
createMapping(columnName, selectedToColumn);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
setSelectedToColumn(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공통 매핑 생성 함수
|
||||||
|
const createMapping = (fromColumnName: string, toColumnName: string) => {
|
||||||
|
const newMappings = columnMappings.map((mapping) => {
|
||||||
|
if (mapping.toColumnName === toColumnName) {
|
||||||
|
return {
|
||||||
|
...mapping,
|
||||||
|
fromColumnName: fromColumnName,
|
||||||
|
defaultValue: "", // 매핑이 설정되면 기본값 초기화
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnMappings(newMappings);
|
||||||
|
updateSettings(newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 컬럼 클릭 핸들러
|
||||||
|
const handleToColumnClick = (toColumnName: string) => {
|
||||||
|
const currentMapping = columnMappings.find((m) => m.toColumnName === toColumnName);
|
||||||
|
|
||||||
|
// 이미 매핑된 컬럼인 경우 처리하지 않음
|
||||||
|
if (currentMapping?.fromColumnName) return;
|
||||||
|
|
||||||
|
if (selectedToColumn === toColumnName) {
|
||||||
|
setSelectedToColumn(null);
|
||||||
|
} else {
|
||||||
|
setSelectedToColumn(toColumnName);
|
||||||
|
|
||||||
|
// FROM 컬럼이 이미 선택되어 있으면 매핑 시도
|
||||||
|
if (selectedFromColumn) {
|
||||||
|
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
|
||||||
|
const toColumn = toTableColumns.find((col) => col.columnName === toColumnName);
|
||||||
|
|
||||||
|
if (fromColumn && toColumn) {
|
||||||
|
// 데이터 타입 호환성 체크
|
||||||
|
if (fromColumn.dataType !== toColumn.dataType) {
|
||||||
|
alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 생성
|
||||||
|
createMapping(selectedFromColumn, toColumnName);
|
||||||
|
setSelectedFromColumn(null);
|
||||||
|
setSelectedToColumn(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본값 변경 핸들러
|
||||||
|
const handleDefaultValueChange = (toColumnName: string, value: string) => {
|
||||||
|
const newMappings = columnMappings.map((mapping) => {
|
||||||
|
if (mapping.toColumnName === toColumnName) {
|
||||||
|
return {
|
||||||
|
...mapping,
|
||||||
|
fromColumnName: value.trim() ? undefined : mapping.fromColumnName, // 기본값이 있으면 매핑 제거
|
||||||
|
defaultValue: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnMappings(newMappings);
|
||||||
|
updateSettings(newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 제거 핸들러
|
||||||
|
const handleRemoveMapping = (toColumnName: string) => {
|
||||||
|
const newMappings = columnMappings.map((mapping) => {
|
||||||
|
if (mapping.toColumnName === toColumnName) {
|
||||||
|
return {
|
||||||
|
...mapping,
|
||||||
|
fromColumnName: undefined,
|
||||||
|
defaultValue: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnMappings(newMappings);
|
||||||
|
updateSettings(newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// FROM 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성 + 1대1 매핑 제약)
|
||||||
|
const isFromColumnClickable = (fromColumn: ColumnInfo) => {
|
||||||
|
// 이미 다른 TO 컬럼과 매핑된 FROM 컬럼은 클릭 불가
|
||||||
|
const isAlreadyMapped = columnMappings.some((mapping) => mapping.fromColumnName === fromColumn.columnName);
|
||||||
|
if (isAlreadyMapped) return false;
|
||||||
|
|
||||||
|
if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능
|
||||||
|
|
||||||
|
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
|
||||||
|
if (!toColumn) return true;
|
||||||
|
|
||||||
|
return fromColumn.dataType === toColumn.dataType;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TO 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성)
|
||||||
|
const isToColumnClickable = (toColumn: ColumnInfo) => {
|
||||||
|
const currentMapping = columnMappings.find((m) => m.toColumnName === toColumn.columnName);
|
||||||
|
|
||||||
|
// 이미 매핑된 컬럼은 클릭 불가
|
||||||
|
if (currentMapping?.fromColumnName) return false;
|
||||||
|
|
||||||
|
// 기본값이 설정된 컬럼은 클릭 불가
|
||||||
|
if (currentMapping?.defaultValue && currentMapping.defaultValue.trim()) return false;
|
||||||
|
|
||||||
|
if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능
|
||||||
|
|
||||||
|
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
|
||||||
|
if (!fromColumn) return true;
|
||||||
|
|
||||||
|
return fromColumn.dataType === toColumn.dataType;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
{/* 헤더 섹션 */}
|
||||||
|
<Card className="mb-6 from-blue-50 to-green-50 py-2">
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="text-sm leading-relaxed text-gray-700">
|
||||||
|
양쪽 테이블의 컬럼을 클릭하여 매핑하거나, 대상 컬럼에 기본값을 입력하세요. 같은 데이터 타입의 컬럼만 매핑
|
||||||
|
가능합니다. 하나의 FROM 컬럼은 하나의 TO 컬럼에만 매핑 가능합니다.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<ColumnTableSection
|
||||||
|
type="from"
|
||||||
|
tableName={fromTableName || "소스 테이블"}
|
||||||
|
columns={fromTableColumns}
|
||||||
|
selectedColumn={selectedFromColumn}
|
||||||
|
onColumnClick={handleFromColumnClick}
|
||||||
|
searchTerm={fromSearchTerm}
|
||||||
|
onSearchChange={setFromSearchTerm}
|
||||||
|
dataTypeFilter={fromDataTypeFilter}
|
||||||
|
onDataTypeFilterChange={setFromDataTypeFilter}
|
||||||
|
showMappedOnly={fromShowMappedOnly}
|
||||||
|
onShowMappedOnlyChange={setFromShowMappedOnly}
|
||||||
|
showUnmappedOnly={fromShowUnmappedOnly}
|
||||||
|
onShowUnmappedOnlyChange={setFromShowUnmappedOnly}
|
||||||
|
columnMappings={columnMappings}
|
||||||
|
isColumnClickable={isFromColumnClickable}
|
||||||
|
oppositeSelectedColumn={selectedToColumn}
|
||||||
|
oppositeColumns={toTableColumns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColumnTableSection
|
||||||
|
type="to"
|
||||||
|
tableName={toTableName || "대상 테이블"}
|
||||||
|
columns={toTableColumns}
|
||||||
|
selectedColumn={selectedToColumn}
|
||||||
|
onColumnClick={handleToColumnClick}
|
||||||
|
searchTerm={toSearchTerm}
|
||||||
|
onSearchChange={setToSearchTerm}
|
||||||
|
dataTypeFilter={toDataTypeFilter}
|
||||||
|
onDataTypeFilterChange={setToDataTypeFilter}
|
||||||
|
showMappedOnly={toShowMappedOnly}
|
||||||
|
onShowMappedOnlyChange={setToShowMappedOnly}
|
||||||
|
showUnmappedOnly={toShowUnmappedOnly}
|
||||||
|
onShowUnmappedOnlyChange={setToShowUnmappedOnly}
|
||||||
|
columnMappings={columnMappings}
|
||||||
|
onDefaultValueChange={handleDefaultValueChange}
|
||||||
|
onRemoveMapping={handleRemoveMapping}
|
||||||
|
isColumnClickable={isToColumnClickable}
|
||||||
|
oppositeSelectedColumn={selectedFromColumn}
|
||||||
|
oppositeColumns={fromTableColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빠른 필터 액션 */}
|
||||||
|
<Card className="mt-6 py-2">
|
||||||
|
<CardContent className="flex flex-wrap gap-2 p-3">
|
||||||
|
<span className="self-center text-sm font-medium text-gray-700">빠른 필터:</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setFromSearchTerm("");
|
||||||
|
setToSearchTerm("");
|
||||||
|
setFromDataTypeFilter("");
|
||||||
|
setToDataTypeFilter("");
|
||||||
|
setFromShowMappedOnly(false);
|
||||||
|
setFromShowUnmappedOnly(false);
|
||||||
|
setToShowMappedOnly(false);
|
||||||
|
setToShowUnmappedOnly(false);
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
전체 보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// FROM: 미매핑만, TO: 미설정만
|
||||||
|
setFromShowMappedOnly(false);
|
||||||
|
setFromShowUnmappedOnly(true);
|
||||||
|
setToShowMappedOnly(false);
|
||||||
|
setToShowUnmappedOnly(true);
|
||||||
|
}}
|
||||||
|
className="h-7 bg-orange-100 text-xs text-orange-700 hover:bg-orange-200"
|
||||||
|
>
|
||||||
|
미매핑만 보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// FROM: 매핑됨만, TO: 설정됨만
|
||||||
|
setFromShowMappedOnly(true);
|
||||||
|
setFromShowUnmappedOnly(false);
|
||||||
|
setToShowMappedOnly(true);
|
||||||
|
setToShowUnmappedOnly(false);
|
||||||
|
}}
|
||||||
|
className="h-7 bg-green-100 text-xs text-green-700 hover:bg-green-200"
|
||||||
|
>
|
||||||
|
매핑됨만 보기
|
||||||
|
</Button>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
FROM: {fromTableColumns.length}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
TO: {toTableColumns.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 매핑 통계 */}
|
||||||
|
<Card className="mt-4 bg-gradient-to-r from-gray-50 to-gray-100 py-2">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-800">매핑 진행 상황</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
총 {toTableColumns.length}개 컬럼 중{" "}
|
||||||
|
<span className="font-bold text-blue-600">
|
||||||
|
{columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length}
|
||||||
|
개
|
||||||
|
</span>{" "}
|
||||||
|
완료
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-gray-800">
|
||||||
|
{Math.round(
|
||||||
|
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
||||||
|
toTableColumns.length) *
|
||||||
|
100,
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">완료율</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
||||||
|
toTableColumns.length) *
|
||||||
|
100
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { checkComponentDuplicate } from "@/lib/api/componentApi";
|
|
||||||
|
|
||||||
export const useComponentDuplicateCheck = (componentCode: string, enabled: boolean = true) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["componentDuplicateCheck", componentCode],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await checkComponentDuplicate(componentCode);
|
|
||||||
console.log(`🔍 중복 체크 응답 데이터:`, { componentCode, result, isDuplicate: result.isDuplicate });
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
enabled: enabled && !!componentCode && componentCode.length > 0,
|
|
||||||
staleTime: 0, // 항상 최신 데이터 확인
|
|
||||||
retry: false, // 실패 시 재시도 안함
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -358,7 +358,7 @@ export class DataFlowAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 컬럼 정보 조회
|
* 테이블 컬럼 정보 조회 (모든 컬럼)
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
static async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -369,7 +369,7 @@ export class DataFlowAPI {
|
||||||
total: number;
|
total: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}>
|
}>
|
||||||
>(`/table-management/tables/${tableName}/columns`);
|
>(`/table-management/tables/${tableName}/columns?size=1000`);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
|
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
|
||||||
|
|
|
||||||
|
|
@ -220,9 +220,9 @@ export const tableTypeApi = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 테이블 컬럼 정보 조회
|
// 테이블 컬럼 정보 조회 (모든 컬럼)
|
||||||
getColumns: async (tableName: string): Promise<any[]> => {
|
getColumns: async (tableName: string): Promise<any[]> => {
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
|
||||||
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
|
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
|
||||||
const data = response.data.data || response.data;
|
const data = response.data.data || response.data;
|
||||||
return data.columns || data || [];
|
return data.columns || data || [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo "백엔드 권한 문제 긴급 수정"
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔧 백엔드 컨테이너 권한 문제를 수정합니다..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. 기존 백엔드 컨테이너 중지 및 제거
|
||||||
|
echo "1. 기존 백엔드 컨테이너 중지 중..."
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml down -v --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
|
# 강제로 컨테이너 제거
|
||||||
|
docker stop pms-backend-prod 2>/dev/null || true
|
||||||
|
docker rm pms-backend-prod 2>/dev/null || true
|
||||||
|
|
||||||
|
# 2. 기존 이미지 제거 (캐시 무효화)
|
||||||
|
echo "2. 기존 백엔드 이미지 제거 중..."
|
||||||
|
docker rmi $(docker images | grep -E "(pms-backend|erp-node.*backend)" | awk '{print $3}') 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. 새로운 이미지 빌드
|
||||||
|
echo "3. 수정된 백엔드 이미지 빌드 중..."
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache --pull
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ 백엔드 빌드 실패"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. 백엔드 컨테이너 시작
|
||||||
|
echo "4. 백엔드 컨테이너 시작 중..."
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml up -d
|
||||||
|
|
||||||
|
# 5. 잠시 대기 후 상태 확인
|
||||||
|
echo "5. 백엔드 서비스 안정화 대기 중... (30초)"
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# 6. 상태 확인
|
||||||
|
echo "6. 백엔드 컨테이너 상태 확인:"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "백엔드 최근 로그:"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=15
|
||||||
|
|
||||||
|
# 7. 헬스체크
|
||||||
|
echo ""
|
||||||
|
echo "7. 헬스체크 수행 중..."
|
||||||
|
for i in {1..10}; do
|
||||||
|
if curl -s http://localhost:8080/health >/dev/null 2>&1; then
|
||||||
|
echo " ✅ 백엔드 서비스 정상 복구!"
|
||||||
|
echo ""
|
||||||
|
echo "🎉 수정 완료! 백엔드가 정상적으로 실행 중입니다."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo " ⏳ 백엔드 응답 대기 중... ($i/10)"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " ❌ 여전히 문제가 있습니다. 로그를 확인해주세요:"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔧 추가 디버깅 명령어:"
|
||||||
|
echo " 실시간 로그: docker-compose -f docker/prod/docker-compose.backend.prod.yml logs -f"
|
||||||
|
echo " 컨테이너 접속: docker exec -it pms-backend-prod bash"
|
||||||
|
echo " 권한 확인: docker exec -it pms-backend-prod ls -la /app/"
|
||||||
|
|
@ -117,16 +117,35 @@ echo ""
|
||||||
|
|
||||||
# 백엔드 헬스체크 (최대 60초 대기)
|
# 백엔드 헬스체크 (최대 60초 대기)
|
||||||
echo "백엔드 헬스체크..."
|
echo "백엔드 헬스체크..."
|
||||||
|
backend_healthy=false
|
||||||
for i in {1..12}; do
|
for i in {1..12}; do
|
||||||
if curl -s http://localhost:8080/actuator/health >/dev/null 2>&1; then
|
if curl -s http://localhost:8080/health >/dev/null 2>&1; then
|
||||||
echo " ✅ 백엔드 서비스 정상"
|
echo " ✅ 백엔드 서비스 정상"
|
||||||
|
backend_healthy=true
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
echo " ⏳ 백엔드 응답 대기 중... ($i/12)"
|
echo " ⏳ 백엔드 응답 대기 중... ($i/12)"
|
||||||
|
if [ $i -eq 6 ]; then
|
||||||
|
echo " 📋 백엔드 컨테이너 상태 확인:"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
|
||||||
|
echo " 📋 백엔드 로그 (최근 10줄):"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=10
|
||||||
|
fi
|
||||||
sleep 5
|
sleep 5
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$backend_healthy" = false ]; then
|
||||||
|
echo " ❌ 백엔드 헬스체크 실패"
|
||||||
|
echo " 📋 상세 진단 정보:"
|
||||||
|
echo " 컨테이너 상태:"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
|
||||||
|
echo " 최근 로그:"
|
||||||
|
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20
|
||||||
|
echo " 포트 8080 사용 현황:"
|
||||||
|
netstat -tln 2>/dev/null | grep ':8080' || echo " 포트 8080이 사용되지 않음"
|
||||||
|
fi
|
||||||
|
|
||||||
# 프론트엔드 헬스체크 (최대 30초 대기)
|
# 프론트엔드 헬스체크 (최대 30초 대기)
|
||||||
echo "프론트엔드 헬스체크..."
|
echo "프론트엔드 헬스체크..."
|
||||||
for i in {1..6}; do
|
for i in {1..6}; do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue